refactor(player): E.28 — extract storage + hotkey helpers cluster (#592)

Two small file-private bits move out of playerStore.ts:

  - `src/store/queueVisibilityStorage.ts` — `readInitialQueueVisibility`
    and `persistQueueVisibility` (the QueuePanel show/hide toggle
    survives reloads via a localStorage round-trip; SSR / private-mode
    failures swallowed silently).
  - `src/store/queueUndoHotkey.ts` — `installQueueUndoHotkey` (Ctrl+Z /
    Cmd+Z queue undo, Ctrl+Shift+Z redo, document-capture; skips text
    fields so native text undo stays; idempotent via window-scoped
    flag; mini-player window skipped). Added a `_resetQueueUndoHotkeyForTest`
    that removes the keydown listener too (needed so vitest specs
    don't accumulate handlers across tests).

playerStore re-exports `installQueueUndoHotkey` so bootstrap + tests
keep their existing `from './playerStore'` imports. The
queue-visibility helpers were file-private; no re-exports.

Side-cleanup: `getWindowKind` import is gone from playerStore (only
the moved hotkey installer used it).

16 focused tests pin the storage round-trip, the idempotency flag, the
modifier + key matching, the editable-target skip, and the
preventDefault contract.

playerStore 1940 → 1879 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 19:41:57 +02:00
committed by GitHub
parent 013d6144ca
commit b0418bf920
5 changed files with 285 additions and 70 deletions
+9 -70
View File
@@ -120,11 +120,20 @@ import {
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
import { initAudioListeners } from './initAudioListeners';
import { installQueueUndoHotkey } from './queueUndoHotkey';
import {
persistQueueVisibility,
readInitialQueueVisibility,
} from './queueVisibilityStorage';
// Re-export so MainApp + the 3 playerStore characterization tests keep
// their existing `from './playerStore'` imports.
export { initAudioListeners };
// Re-export so bootstrap.ts + bootstrap.test keep their existing
// `from './playerStore'` imports.
export { installQueueUndoHotkey };
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
export { flushPlayQueuePosition };
@@ -138,7 +147,6 @@ export {
subscribePlaybackProgress,
type PlaybackProgressSnapshot,
};
import { getWindowKind } from '../app/windowKind';
import {
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
@@ -166,29 +174,6 @@ export {
registerQueueListScrollTopReader,
};
const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible';
function readInitialQueueVisibility(): boolean {
if (typeof window === 'undefined') return true;
try {
const raw = window.localStorage.getItem(QUEUE_VISIBILITY_STORAGE_KEY);
if (raw === 'true') return true;
if (raw === 'false') return false;
} catch {
// ignore storage access failures and fall back to default
}
return true;
}
function persistQueueVisibility(visible: boolean): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(QUEUE_VISIBILITY_STORAGE_KEY, String(visible));
} catch {
// ignore storage access failures
}
}
export interface Track {
id: string;
title: string;
@@ -1892,49 +1877,3 @@ usePlayerStore.subscribe((state, prev) => {
});
});
const QUEUE_UNDO_HOTKEY_FLAG = '__psyQueueUndoListenerInstalled';
/** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */
function keyboardEventTargetIsEditableField(e: KeyboardEvent): boolean {
for (const n of e.composedPath()) {
if (!(n instanceof HTMLElement)) continue;
const tag = n.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (n.isContentEditable) return true;
}
return false;
}
/**
* Ctrl+Z / Cmd+Z undo and Ctrl+Shift+Z / Cmd+Shift+Z redo for the queue — document capture.
* Call once at startup (e.g. from main.tsx); idempotent. Skips the mini-player window.
*/
export function installQueueUndoHotkey(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[QUEUE_UNDO_HOTKEY_FLAG]) return;
if (getWindowKind() === 'mini') return;
w[QUEUE_UNDO_HOTKEY_FLAG] = true;
document.addEventListener(
'keydown',
(e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.code !== 'KeyZ' && String(e.key || '').toLowerCase() !== 'z') return;
if (keyboardEventTargetIsEditableField(e)) return;
if (e.shiftKey) {
if (usePlayerStore.getState().redoLastQueueEdit()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
if (usePlayerStore.getState().undoLastQueueEdit()) {
e.preventDefault();
e.stopPropagation();
}
},
true,
);
}