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:
Psychotoxical
2026-06-30 08:20:11 +02:00
parent 209dd61442
commit 7ad196711e
74 changed files with 78 additions and 78 deletions
+335
View File
@@ -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 windows 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 };
}
+29
View File
@@ -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');
});
});
+13
View File
@@ -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;
}
+33
View File
@@ -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);
}
+108
View File
@@ -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);
});
});
+91
View File
@@ -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 },
};
}
+61
View File
@@ -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,
]);
}
+63
View File
@@ -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;
}
+77
View File
@@ -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;
}
+69
View File
@@ -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);
}
+74
View File
@@ -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';
+45
View File
@@ -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 };
};