mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(lib): move generic hooks, DnD engine, shortcut contract to lib
Continue M4 lib/ de-flattening with domain-agnostic infra: - lib/hooks/: 9 pure generic hooks (useDebouncedValue, useIsMobile, useLongPressAction, useRangeSelection, useResizeClientHeight, useWindowVisibility, useSystemPrefersDark, useVirtualizerScrollMargin, useRemeasureGridVirtualizer) — all PURE (only react/zustand/tanstack deps). - lib/dnd/DragDropContext.tsx: the generic mouse-event DnD engine (WebKitGTK HTML5-DnD workaround, ~24 importers, self-contained) — empties src/contexts/. - lib/shortcuts/: shortcutActions + shortcutTypes (the action-id + binding contract; registry/dispatch/bindings stay app-level and import the contract, app->lib direction). useWindowFullscreenState deliberately NOT moved — kept in hooks/ as app-shell per the handoff iron-rule list (overrides its pure-helper appearance). Pure move via deep @/lib/* specifiers. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Mouse-event-based Drag & Drop system.
|
||||
*
|
||||
* Replaces the HTML5 Drag & Drop API for cross-component drags (song → queue,
|
||||
* album → queue) because WebKitGTK on Linux always shows a "forbidden" cursor
|
||||
* during native HTML5 DnD and there is no way to fix it at the GTK level
|
||||
* without breaking DnD entirely.
|
||||
*
|
||||
* This system uses mousedown / mousemove / mouseup which keeps cursor control
|
||||
* in CSS and avoids the native DnD subsystem completely.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────
|
||||
export interface DragPayload {
|
||||
/** Serialised JSON identical to what was previously in dataTransfer */
|
||||
data: string;
|
||||
/** Label shown on the ghost element */
|
||||
label: string;
|
||||
/** Optional cover URL for the ghost */
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
payload: DragPayload | null;
|
||||
position: { x: number; y: number };
|
||||
/** `queue_reorder` only: true when cursor is outside every registered queue rect */
|
||||
queueReorderOutside?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropContextValue {
|
||||
/** Begin a drag. Called from mousedown (after threshold). */
|
||||
startDrag: (payload: DragPayload, x: number, y: number) => void;
|
||||
/** Current drag payload (null when idle). */
|
||||
payload: DragPayload | null;
|
||||
/** Whether a drag is in progress. */
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
const Ctx = createContext<DragDropContextValue>({
|
||||
startDrag: () => {},
|
||||
payload: null,
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
// useDragDrop / useDragSource / registerQueueDragHitTest are the documented
|
||||
// drag-drop API surface, intentionally co-located with DragDropProvider; this
|
||||
// HMR-only rule does not warrant fragmenting the context module.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useDragDrop = () => useContext(Ctx);
|
||||
|
||||
function isQueueReorderDrag(data: string): boolean {
|
||||
try {
|
||||
return JSON.parse(data).type === 'queue_reorder';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hit-tests for queue UI rects (main sidebar + mini-player list). Used so the drag ghost only shows “remove” outside those bounds. */
|
||||
const queueDragHitTests: Array<(x: number, y: number) => boolean> = [];
|
||||
|
||||
/**
|
||||
* Register a function that returns true when (clientX, clientY) lies inside
|
||||
* this window’s queue drop area. Unregister on cleanup.
|
||||
*/
|
||||
// Part of the drag-drop API co-located with the provider (see note above).
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function registerQueueDragHitTest(fn: (x: number, y: number) => boolean): () => void {
|
||||
queueDragHitTests.push(fn);
|
||||
return () => {
|
||||
const i = queueDragHitTests.indexOf(fn);
|
||||
if (i >= 0) queueDragHitTests.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function computeQueueReorderOutside(clientX: number, clientY: number): boolean {
|
||||
if (queueDragHitTests.length === 0) return false;
|
||||
const inside = queueDragHitTests.some((t) => t(clientX, clientY));
|
||||
return !inside;
|
||||
}
|
||||
|
||||
// ── Ghost overlay ─────────────────────────────────────────────────
|
||||
function DragGhost({ state }: { state: DragState }) {
|
||||
if (!state.payload) return null;
|
||||
const { label, coverUrl, data } = state.payload;
|
||||
const queueReorder = isQueueReorderDrag(data);
|
||||
const showTrash = queueReorder && state.queueReorderOutside === true;
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.position.x + 12,
|
||||
top: state.position.y - 20,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 99999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'var(--bg-card, #1e1e2e)',
|
||||
border: '1px solid var(--border, rgba(255,255,255,0.1))',
|
||||
borderRadius: 8,
|
||||
padding: '6px 12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
color: 'var(--text-primary, #fff)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
maxWidth: 280,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
opacity: 0.95,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{showTrash && (
|
||||
<Trash2
|
||||
size={16}
|
||||
strokeWidth={2.25}
|
||||
aria-hidden
|
||||
style={{ flexShrink: 0, color: 'var(--danger, var(--danger, #f38ba8))' }}
|
||||
/>
|
||||
)}
|
||||
{coverUrl && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 4,
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────
|
||||
export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<DragState>({
|
||||
payload: null,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const stateRef = useRef(state);
|
||||
// React Compiler refs rule: latest-value box kept in sync for use in callbacks; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
stateRef.current = state;
|
||||
|
||||
const startDrag = useCallback(
|
||||
(payload: DragPayload, x: number, y: number) => {
|
||||
// Clear any text selection the browser may have started during the
|
||||
// threshold detection phase (mousedown → mousemove before startDrag).
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setState({
|
||||
payload,
|
||||
position: { x, y },
|
||||
...(isQueueReorderDrag(payload.data)
|
||||
? { queueReorderOutside: computeQueueReorderOutside(x, y) }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Global mousemove + mouseup listeners (only while dragging)
|
||||
useEffect(() => {
|
||||
if (!state.payload) return;
|
||||
|
||||
let ended = false;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// preventDefault stops the browser from treating the mouse movement as
|
||||
// a text-selection drag, which causes element highlighting and
|
||||
// horizontal auto-scroll in grid containers.
|
||||
e.preventDefault();
|
||||
setState((prev) => {
|
||||
if (!prev.payload) return prev;
|
||||
const pos = { x: e.clientX, y: e.clientY };
|
||||
if (!isQueueReorderDrag(prev.payload.data)) {
|
||||
return { ...prev, position: pos };
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
position: pos,
|
||||
queueReorderOutside: computeQueueReorderOutside(pos.x, pos.y),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
|
||||
const endDrag = (dispatchDrop: boolean) => {
|
||||
if (ended || !stateRef.current.payload) return;
|
||||
ended = true;
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
if (dispatchDrop) {
|
||||
const p = stateRef.current.position;
|
||||
const pl = stateRef.current.payload;
|
||||
const evt = new CustomEvent('psy-drop', {
|
||||
bubbles: true,
|
||||
detail: pl
|
||||
? { ...pl, clientX: p.x, clientY: p.y }
|
||||
: pl,
|
||||
});
|
||||
const el = document.elementFromPoint(p.x, p.y);
|
||||
if (el) el.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
setState({ payload: null, position: { x: 0, y: 0 } });
|
||||
};
|
||||
|
||||
const onUp = () => endDrag(true);
|
||||
/** Wayland: webview may not get `mouseup` when the pointer leaves the surface — clear the ghost without a drop. */
|
||||
const onBlur = () => endDrag(false);
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) endDrag(false);
|
||||
};
|
||||
const onPointerCancel = () => endDrag(false);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
endDrag(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
document.addEventListener('pointerup', onUp, true);
|
||||
document.addEventListener('pointercancel', onPointerCancel, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
|
||||
document.body.classList.add('psy-dragging');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
document.removeEventListener('pointerup', onUp, true);
|
||||
document.removeEventListener('pointercancel', onPointerCancel, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
document.body.classList.remove('psy-dragging');
|
||||
};
|
||||
}, [state.payload]);
|
||||
|
||||
const ctxValue: DragDropContextValue = {
|
||||
startDrag,
|
||||
payload: state.payload,
|
||||
isDragging: state.payload !== null,
|
||||
};
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={ctxValue}>
|
||||
{children}
|
||||
<DragGhost state={state} />
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── useDragSource hook ────────────────────────────────────────────
|
||||
const DRAG_THRESHOLD = 5; // px before drag starts
|
||||
|
||||
/**
|
||||
* Returns an onMouseDown handler for a draggable element.
|
||||
* Usage: <div {...useDragSource(payload)} />
|
||||
*/
|
||||
// Part of the drag-drop API co-located with the provider (see note above).
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDragSource(getPayload: () => DragPayload) {
|
||||
const { startDrag } = useDragDrop();
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const payloadRef = useRef(getPayload);
|
||||
// React Compiler refs rule: latest-value box kept in sync for use in handlers; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
payloadRef.current = getPayload;
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only left-click
|
||||
if (e.button !== 0) return;
|
||||
// Prevent the browser from starting a text-selection drag during the
|
||||
// threshold detection phase (mousedown → mousemove before startDrag).
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
startPosRef.current = { x: startX, y: startY };
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (!startPosRef.current) return;
|
||||
const dx = me.clientX - startX;
|
||||
const dy = me.clientY - startY;
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
startPosRef.current = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
startDrag(payloadRef.current(), me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
startPosRef.current = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[startDrag],
|
||||
);
|
||||
|
||||
return { onMouseDown };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebouncedValue } from '@/lib/hooks/useDebouncedValue';
|
||||
|
||||
describe('useDebouncedValue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('updates after delay when value changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useDebouncedValue(value, 1000),
|
||||
{ initialProps: { value: 'a' } },
|
||||
);
|
||||
|
||||
expect(result.current).toBe('a');
|
||||
rerender({ value: 'b' });
|
||||
expect(result.current).toBe('a');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current).toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Returns `value` after it stays unchanged for `delayMs`. */
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 800;
|
||||
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
let mql: MediaQueryList | null = null;
|
||||
|
||||
function getMql(): MediaQueryList {
|
||||
if (!mql) mql = window.matchMedia(query);
|
||||
return mql;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const m = getMql();
|
||||
m.addEventListener('change', cb);
|
||||
return () => m.removeEventListener('change', cb);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return getMql().matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the viewport width is below 800px.
|
||||
* Updates in real-time on resize via `matchMedia`.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
|
||||
|
||||
function makePointerDown(overrides: Partial<PointerEvent> = {}): PointerEvent {
|
||||
return {
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
stopPropagation: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
function makeClick(): MouseEvent {
|
||||
return { stopPropagation: vi.fn() } as unknown as MouseEvent;
|
||||
}
|
||||
|
||||
describe('useLongPressAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls onShortPress after a quick press and click', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.pressBind.onClick(makeClick());
|
||||
});
|
||||
|
||||
expect(onShortPress).toHaveBeenCalledTimes(1);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onLongPress after the hold duration and suppresses the follow-up click', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.pressBind.onClick(makeClick());
|
||||
});
|
||||
|
||||
expect(onShortPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the hold animation shortly before the long-press fires', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({
|
||||
onShortPress: vi.fn(),
|
||||
onLongPress: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isHolding).toBe(false);
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.isHolding).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-primary mouse buttons', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown({ button: 2 }));
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(onShortPress).not.toHaveBeenCalled();
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
expect(result.current.isHolding).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_HOLD_MS = 1000;
|
||||
const DEFAULT_ANIM_DELAY_MS = 100;
|
||||
|
||||
interface UseLongPressActionOptions {
|
||||
onShortPress: () => void;
|
||||
onLongPress: () => void;
|
||||
holdMs?: number;
|
||||
animDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short click runs `onShortPress`; hold runs `onLongPress` and suppresses the
|
||||
* synthetic click that follows pointer release.
|
||||
*/
|
||||
export function useLongPressAction({
|
||||
onShortPress,
|
||||
onLongPress,
|
||||
holdMs = DEFAULT_HOLD_MS,
|
||||
animDelayMs = DEFAULT_ANIM_DELAY_MS,
|
||||
}: UseLongPressActionOptions) {
|
||||
const [isHolding, setIsHolding] = useState(false);
|
||||
const longPressTriggeredRef = useRef(false);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const animTimerRef = useRef<number | null>(null);
|
||||
const endListenerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (holdTimerRef.current != null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (animTimerRef.current != null) {
|
||||
window.clearTimeout(animTimerRef.current);
|
||||
animTimerRef.current = null;
|
||||
}
|
||||
if (endListenerRef.current) {
|
||||
document.removeEventListener('pointerup', endListenerRef.current);
|
||||
document.removeEventListener('pointercancel', endListenerRef.current);
|
||||
endListenerRef.current = null;
|
||||
}
|
||||
setIsHolding(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimers(), [clearTimers]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
longPressTriggeredRef.current = false;
|
||||
clearTimers();
|
||||
|
||||
animTimerRef.current = window.setTimeout(() => {
|
||||
animTimerRef.current = null;
|
||||
setIsHolding(true);
|
||||
}, animDelayMs) as unknown as number;
|
||||
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
longPressTriggeredRef.current = true;
|
||||
onLongPress();
|
||||
setIsHolding(false);
|
||||
}, holdMs) as unknown as number;
|
||||
|
||||
const end = () => clearTimers();
|
||||
endListenerRef.current = end;
|
||||
document.addEventListener('pointerup', end);
|
||||
document.addEventListener('pointercancel', end);
|
||||
},
|
||||
[animDelayMs, clearTimers, holdMs, onLongPress],
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
onShortPress();
|
||||
},
|
||||
[onShortPress],
|
||||
);
|
||||
|
||||
return {
|
||||
isHolding,
|
||||
pressBind: { onClick, onPointerDown },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Multi-select state with Shift+Click range support.
|
||||
*
|
||||
* Pass the *currently visible* list (already filtered + sorted in the order
|
||||
* the user sees it) so range expansion follows what's on screen, not the
|
||||
* raw upstream data.
|
||||
*
|
||||
* - Plain click on an item: toggles that item and sets it as the new range anchor.
|
||||
* - Shift-click on a second item: adds every item between the anchor and the
|
||||
* click target (inclusive) to the selection. The anchor moves to the
|
||||
* shift-clicked item so subsequent shift-clicks extend from there.
|
||||
*
|
||||
* The anchor is a ref, not state — moving it does not trigger re-renders.
|
||||
*/
|
||||
export function useRangeSelection<T extends { id: string }>(items: T[]) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const itemsRef = useRef(items);
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
const anchorRef = useRef<string | null>(null);
|
||||
|
||||
const toggleSelect = useCallback((id: string, opts?: { shiftKey?: boolean }) => {
|
||||
// Snapshot the anchor *before* the state updater runs. React strict mode
|
||||
// invokes setState updater functions twice in dev to surface side effects,
|
||||
// so any ref mutation inside the updater would taint the second invocation
|
||||
// and the replay would miss the range branch.
|
||||
const anchorAtCallTime = anchorRef.current;
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
const list = itemsRef.current;
|
||||
|
||||
if (opts?.shiftKey && anchorAtCallTime && anchorAtCallTime !== id) {
|
||||
const startIdx = list.findIndex(x => x.id === anchorAtCallTime);
|
||||
const endIdx = list.findIndex(x => x.id === id);
|
||||
if (startIdx >= 0 && endIdx >= 0) {
|
||||
const lo = Math.min(startIdx, endIdx);
|
||||
const hi = Math.max(startIdx, endIdx);
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
next.add(list[i].id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
anchorRef.current = id;
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
anchorRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { selectedIds, setSelectedIds, toggleSelect, clearSelection };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
/** When grid column count or row height estimate changes, TanStack can keep stale offsets until scroll — force a remeasure. */
|
||||
export function useRemeasureGridVirtualizer<TScroll extends Element, TItem extends Element>(
|
||||
virtualizer: Virtualizer<TScroll, TItem>,
|
||||
args: {
|
||||
active: boolean;
|
||||
gridCols: number;
|
||||
rowHeightEst: number;
|
||||
virtualRowCount: number;
|
||||
},
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!args.active || args.virtualRowCount === 0) return;
|
||||
virtualizer.measure();
|
||||
}, [
|
||||
args.active,
|
||||
args.gridCols,
|
||||
args.rowHeightEst,
|
||||
args.virtualRowCount,
|
||||
virtualizer,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type RefObject, useLayoutEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Track an element's `clientHeight` (ResizeObserver). Used so virtualizers can
|
||||
* set `overscan` to roughly one viewport of rows beyond the visible range.
|
||||
*/
|
||||
export function useElementClientHeightById(elementId: string, fallback = 800): number {
|
||||
const [h, setH] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
const el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
|
||||
if (!el) {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setH(fallback);
|
||||
return;
|
||||
}
|
||||
const update = () => setH(el.clientHeight);
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
update();
|
||||
return () => ro.disconnect();
|
||||
}, [elementId, fallback]);
|
||||
return h;
|
||||
}
|
||||
|
||||
export function useRefElementClientHeight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
fallback = 600,
|
||||
): number {
|
||||
const [h, setH] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const update = () => setH(el.clientHeight);
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
update();
|
||||
return () => ro.disconnect();
|
||||
}, [ref, fallback]);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** ResizeObserver on a concrete element (e.g. callback-ref state for in-page scrollers). */
|
||||
export function useElementClientHeightForElement(
|
||||
element: HTMLElement | null,
|
||||
fallback = 600,
|
||||
): number {
|
||||
const [h, setH] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
if (!element) {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setH(fallback);
|
||||
return;
|
||||
}
|
||||
const update = () => setH(element.clientHeight);
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(element);
|
||||
update();
|
||||
return () => ro.disconnect();
|
||||
}, [element, fallback]);
|
||||
return h;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
/**
|
||||
* Tracks whether the OS prefers a dark color scheme, for the "follow system"
|
||||
* theme scheduler mode.
|
||||
*
|
||||
* The native Tauri window theme API is the primary source: on Linux the Web
|
||||
* `prefers-color-scheme` media query is unreliable through WebKitGTK (Tauri
|
||||
* does not reliably forward the system preference — it tends to report `light`
|
||||
* regardless), whereas `theme()` / `onThemeChanged` read the OS theme natively
|
||||
* (via the XDG portal on Linux). `matchMedia` is only a fallback for the
|
||||
* frontend-only dev shell where the Tauri IPC is absent.
|
||||
*
|
||||
* `theme()` resolves the OS theme correctly at startup; `onThemeChanged` gives
|
||||
* an instant live update where the platform forwards it (macOS / Windows). On
|
||||
* some Linux setups (tao does not forward the portal change live) the event
|
||||
* never fires, so a light/dark flip is only reflected after an app restart —
|
||||
* the UI surfaces that note in the system scheduler mode.
|
||||
*/
|
||||
|
||||
let isDark = false;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function setDark(next: boolean): void {
|
||||
if (next === isDark) return;
|
||||
isDark = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Begin watching once the first subscriber mounts (idempotent). */
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
setDark((await win.theme()) === 'dark');
|
||||
// Instant updates where the platform forwards them (macOS / Windows).
|
||||
await win.onThemeChanged(({ payload }) => setDark(payload === 'dark'));
|
||||
} catch {
|
||||
// No Tauri IPC (frontend-only dev) — best-effort Web fallback.
|
||||
try {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
setDark(mql.matches);
|
||||
mql.addEventListener('change', (e) => setDark(e.matches));
|
||||
} catch {
|
||||
/* default: light */
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
start();
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return isDark;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `true` when the OS theme is dark. Updates live on OS theme changes. */
|
||||
export function useSystemPrefersDark(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Distance between a virtualizer's wrapper element and its scroll element.
|
||||
* Pass as `scrollMargin` to `useVirtualizer`, and subtract from `vItem.start`
|
||||
* when positioning rendered rows. Without it, `@tanstack/react-virtual`
|
||||
* places rows relative to the scroll-element top — so a wrapper that sits
|
||||
* below other content (sticky header, tracklist, hero) ends up with rows at
|
||||
* the wrong Y, and rows still in the viewport may unmount.
|
||||
*
|
||||
* Re-measures via `ResizeObserver` on the scroll element and its first child
|
||||
* so content growing above the wrapper updates the offset.
|
||||
*/
|
||||
export function useVirtualizerScrollMargin(
|
||||
wrapRef: React.RefObject<HTMLElement | null>,
|
||||
getScrollElement: () => HTMLElement | null,
|
||||
options: {
|
||||
active: boolean;
|
||||
/** Caller-supplied dependencies that affect the wrapper's position. */
|
||||
deps: ReadonlyArray<unknown>;
|
||||
},
|
||||
): number {
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const getterRef = useRef(getScrollElement);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
getterRef.current = getScrollElement;
|
||||
useLayoutEffect(() => {
|
||||
if (!options.active) return;
|
||||
const wrap = wrapRef.current;
|
||||
const scrollEl = getterRef.current();
|
||||
if (!wrap || !scrollEl) return;
|
||||
const measure = () => {
|
||||
const margin =
|
||||
wrap.getBoundingClientRect().top
|
||||
- scrollEl.getBoundingClientRect().top
|
||||
+ scrollEl.scrollTop;
|
||||
setScrollMargin(prev => (Math.abs(prev - margin) < 1 ? prev : margin));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(scrollEl);
|
||||
const scrollContent = scrollEl.firstElementChild as Element | null;
|
||||
if (scrollContent) ro.observe(scrollContent);
|
||||
return () => ro.disconnect();
|
||||
// options.deps is a caller-supplied dependency list spread in on purpose so
|
||||
// this reusable hook re-measures when the caller's layout inputs change;
|
||||
// it cannot be statically verified, which is expected here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options.active, wrapRef, ...options.deps]);
|
||||
return scrollMargin;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
const WindowVisibilityContext = createContext(false);
|
||||
|
||||
/**
|
||||
* Tracks whether the Tauri window is hidden.
|
||||
*
|
||||
* On Windows WebView2, `visibilitychange` and `blur`/`focus` events do not
|
||||
* fire when `win.hide()` is called. We fall back to polling `document.hidden`
|
||||
* OR-ed with `window.__psyHidden` (set from Rust before/after `win.hide()` /
|
||||
* `show()`) — the latter is the reliable signal on WebView2 where
|
||||
* `document.hidden` may stay false. Adaptive interval: slow while hidden
|
||||
* (minimize wakeups), 500 ms while visible (catch show without burning CPU).
|
||||
*/
|
||||
function isWindowHidden() {
|
||||
return document.hidden || !!window.__psyHidden;
|
||||
}
|
||||
|
||||
export function WindowVisibilityProvider({ children }: { children: ReactNode }) {
|
||||
const [hidden, setHidden] = useState(isWindowHidden);
|
||||
const hiddenRef = useRef(hidden);
|
||||
|
||||
useEffect(() => {
|
||||
hiddenRef.current = isWindowHidden();
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const schedule = () => {
|
||||
if (cancelled) return;
|
||||
const interval = hiddenRef.current ? 1000 : 500;
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (cancelled) return;
|
||||
const current = isWindowHidden();
|
||||
if (current !== hiddenRef.current) {
|
||||
hiddenRef.current = current;
|
||||
setHidden(current);
|
||||
}
|
||||
schedule();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
schedule();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WindowVisibilityContext.Provider value={hidden}>
|
||||
{children}
|
||||
</WindowVisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Companion hook intentionally co-located with WindowVisibilityProvider in this
|
||||
// small context module; HMR-only rule does not warrant a separate file.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useWindowVisibility() {
|
||||
return useContext(WindowVisibilityContext);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ─── Shortcut-action subsystem ───────────────────────────────────────────────
|
||||
//
|
||||
// Barrel + contract reference. Split into:
|
||||
// shortcutTypes.ts — shared types
|
||||
// shortcutActionRegistry.ts — SHORTCUT_ACTION_REGISTRY + action id types
|
||||
// shortcutDispatch.ts — runtime + CLI dispatch
|
||||
// shortcutBindings.ts — derived in-app / global binding tables
|
||||
// Existing call sites import from this module unchanged.
|
||||
//
|
||||
// ── The contract ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every shortcut action is one entry in `SHORTCUT_ACTION_REGISTRY`, keyed by a
|
||||
// stable id (`ShortcutAction`). Its `ShortcutActionMeta` declares which of three
|
||||
// independent *trigger surfaces* the action is exposed on — an action may opt
|
||||
// into any combination:
|
||||
//
|
||||
// • inApp? — bindable to a keyboard chord while the MAIN window is focused.
|
||||
// Presence of the `inApp` slot makes the id a `KeyAction`; the
|
||||
// slot's `defaultBinding` is the out-of-box chord (or null =
|
||||
// unbound), `hidden` keeps it out of the Settings UI list.
|
||||
// Surfaced via IN_APP_SHORTCUT_ACTIONS / DEFAULT_IN_APP_BINDINGS;
|
||||
// matched at runtime by `shortcuts/runtime.ts`.
|
||||
// • global? — registrable as an OS-LEVEL global hotkey (fires even when the
|
||||
// app is unfocused). Presence makes the id a `GlobalAction`;
|
||||
// surfaced via GLOBAL_SHORTCUT_ACTIONS / DEFAULT_GLOBAL_SHORTCUTS.
|
||||
// `isGlobalShortcutActionId` is the runtime guard.
|
||||
// • runInMiniWindow — whether the action may run when triggered FROM the
|
||||
// mini-player window. `canRunShortcutActionInMiniWindow` gates
|
||||
// cross-window `shortcut:run-action` events.
|
||||
//
|
||||
// Two extra, surface-independent fields:
|
||||
// • cli? — exposes the action to `psysonic --player <verb>`. No-arg CLI
|
||||
// verbs are auto-collected and dispatched by
|
||||
// `executeCliPlayerCommand`; arg-carrying commands (play-id,
|
||||
// seek-relative, set-volume, set-repeat, set-rating-current)
|
||||
// are handled explicitly there.
|
||||
// • run(ctx) — the handler. `ctx.previewPolicy` ('stop' | 'ignore') decides
|
||||
// whether an active track-preview is interrupted: media keys
|
||||
// pass 'ignore', explicit UI / in-app keys pass 'stop'.
|
||||
//
|
||||
// Dispatch entry points: `executeRuntimeAction` (any trigger surface) and
|
||||
// `executeCliPlayerCommand` (CLI). `isShortcutAction` validates an arbitrary
|
||||
// string against the registry.
|
||||
|
||||
export type {
|
||||
TranslateLike,
|
||||
ShortcutSlot,
|
||||
ActionContext,
|
||||
CliContext,
|
||||
ShortcutActionMeta,
|
||||
} from '@/lib/shortcuts/shortcutTypes';
|
||||
|
||||
export {
|
||||
SHORTCUT_ACTION_REGISTRY,
|
||||
type ShortcutAction,
|
||||
type KeyAction,
|
||||
type GlobalAction,
|
||||
} from '@/config/shortcutActionRegistry';
|
||||
|
||||
export {
|
||||
isShortcutAction,
|
||||
isGlobalShortcutActionId,
|
||||
canRunShortcutActionInMiniWindow,
|
||||
executeRuntimeAction,
|
||||
executeCliPlayerCommand,
|
||||
type RuntimeAction,
|
||||
} from '@/config/shortcutDispatch';
|
||||
|
||||
export {
|
||||
IN_APP_SHORTCUT_ACTIONS,
|
||||
GLOBAL_SHORTCUT_ACTIONS,
|
||||
DEFAULT_IN_APP_BINDINGS,
|
||||
DEFAULT_GLOBAL_SHORTCUTS,
|
||||
} from '@/config/shortcutBindings';
|
||||
@@ -0,0 +1,45 @@
|
||||
// Shared types for the shortcut-action subsystem. The contract overview lives
|
||||
// in the barrel, `shortcutActions.ts`.
|
||||
|
||||
import type { NavigateOptions } from 'react-router-dom';
|
||||
|
||||
export type TranslateLike = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** One bindable slot (in-app OR global). `defaultBinding` is the out-of-box
|
||||
* chord, or `null` for "unbound by default". `hidden` keeps the action out of
|
||||
* the Settings shortcut-list UI (still bindable / dispatchable). */
|
||||
export type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean };
|
||||
|
||||
/** Passed to an action's `run`. `previewPolicy` decides whether an active
|
||||
* track-preview is interrupted: 'stop' for explicit UI / in-app keys, 'ignore'
|
||||
* for hardware media keys. */
|
||||
export type ActionContext = {
|
||||
navigate: (to: string, options?: NavigateOptions) => void;
|
||||
previewPolicy: 'stop' | 'ignore';
|
||||
};
|
||||
|
||||
/** Passed to `executeCliPlayerCommand` — the raw `cli:player-command` payload
|
||||
* plus a navigate fn. */
|
||||
export type CliContext = {
|
||||
navigate: (to: string, options?: NavigateOptions) => void;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Registry entry for one shortcut action. `inApp` / `global` /
|
||||
* `runInMiniWindow` are the three independent trigger surfaces; `cli` and
|
||||
* `run` are surface-independent. See the contract block in `shortcutActions.ts`. */
|
||||
export type ShortcutActionMeta = {
|
||||
/** Localized display label for the Settings UI. */
|
||||
getLabel: (t: TranslateLike) => string;
|
||||
/** Present ⇒ bindable to a main-window keyboard chord (id becomes a `KeyAction`). */
|
||||
inApp?: ShortcutSlot;
|
||||
/** Present ⇒ registrable as an OS-level global hotkey (id becomes a `GlobalAction`). */
|
||||
global?: ShortcutSlot;
|
||||
/** Whether the action may run when triggered from the mini-player window. */
|
||||
runInMiniWindow: boolean;
|
||||
/** The handler. */
|
||||
run: (ctx: ActionContext) => void;
|
||||
/** Present ⇒ exposed to `psysonic --player <verb>`. `command` overrides the
|
||||
* CLI verb used for no-arg dispatch (defaults to the action id). */
|
||||
cli?: { verb: string; description: string; command?: string };
|
||||
};
|
||||
Reference in New Issue
Block a user