From b0418bf9205f57d67f26410a3a748fa949f921cb Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 19:41:57 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.28=20=E2=80=94=20extract?= =?UTF-8?q?=20storage=20+=20hotkey=20helpers=20cluster=20(#592)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/store/playerStore.ts | 79 ++------------ src/store/queueUndoHotkey.test.ts | 130 +++++++++++++++++++++++ src/store/queueUndoHotkey.ts | 62 +++++++++++ src/store/queueVisibilityStorage.test.ts | 52 +++++++++ src/store/queueVisibilityStorage.ts | 32 ++++++ 5 files changed, 285 insertions(+), 70 deletions(-) create mode 100644 src/store/queueUndoHotkey.test.ts create mode 100644 src/store/queueUndoHotkey.ts create mode 100644 src/store/queueVisibilityStorage.test.ts create mode 100644 src/store/queueVisibilityStorage.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e14995ed..6af9da5e 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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; - 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, - ); -} diff --git a/src/store/queueUndoHotkey.test.ts b/src/store/queueUndoHotkey.test.ts new file mode 100644 index 00000000..c3c5d2f3 --- /dev/null +++ b/src/store/queueUndoHotkey.test.ts @@ -0,0 +1,130 @@ +/** + * Smoke + behaviour tests for the queue-undo hotkey installer. Verifies + * idempotency (the second call is a no-op), Ctrl+Z → undo, Ctrl+Shift+Z + * → redo, and the editable-field guard that lets the browser keep + * native text undo inside input/textarea/contenteditable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + windowKindMock: vi.fn(() => 'main' as 'main' | 'mini'), + undoMock: vi.fn(() => true), + redoMock: vi.fn(() => true), +})); + +vi.mock('../app/windowKind', () => ({ getWindowKind: hoisted.windowKindMock })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { + getState: () => ({ + undoLastQueueEdit: hoisted.undoMock, + redoLastQueueEdit: hoisted.redoMock, + }), + }, +})); + +import { _resetQueueUndoHotkeyForTest, installQueueUndoHotkey } from './queueUndoHotkey'; + +beforeEach(() => { + hoisted.windowKindMock.mockReturnValue('main'); + hoisted.undoMock.mockReset(); + hoisted.undoMock.mockReturnValue(true); + hoisted.redoMock.mockReset(); + hoisted.redoMock.mockReturnValue(true); + _resetQueueUndoHotkeyForTest(); +}); + +afterEach(() => { + _resetQueueUndoHotkeyForTest(); +}); + +function fireKey(key: string, opts: Partial = {}, target?: HTMLElement): boolean { + const event = new KeyboardEvent('keydown', { + key, + code: 'Key' + key.toUpperCase(), + bubbles: true, + cancelable: true, + composed: true, + ...opts, + }); + (target ?? document.body).dispatchEvent(event); + return event.defaultPrevented; +} + +describe('installQueueUndoHotkey', () => { + it('is a no-op when window kind is mini', () => { + hoisted.windowKindMock.mockReturnValue('mini'); + installQueueUndoHotkey(); + fireKey('z', { ctrlKey: true }); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + }); + + it('fires undoLastQueueEdit on Ctrl+Z', () => { + installQueueUndoHotkey(); + const prevented = fireKey('z', { ctrlKey: true }); + expect(hoisted.undoMock).toHaveBeenCalledTimes(1); + expect(prevented).toBe(true); + }); + + it('fires redoLastQueueEdit on Ctrl+Shift+Z', () => { + installQueueUndoHotkey(); + fireKey('z', { ctrlKey: true, shiftKey: true }); + expect(hoisted.redoMock).toHaveBeenCalledTimes(1); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + }); + + it('honours Cmd+Z (metaKey) on macOS', () => { + installQueueUndoHotkey(); + fireKey('z', { metaKey: true }); + expect(hoisted.undoMock).toHaveBeenCalledTimes(1); + }); + + it('is idempotent — second install attaches no second listener', () => { + installQueueUndoHotkey(); + installQueueUndoHotkey(); + fireKey('z', { ctrlKey: true }); + expect(hoisted.undoMock).toHaveBeenCalledTimes(1); + }); + + it('does NOT preventDefault when undoLastQueueEdit returns false', () => { + hoisted.undoMock.mockReturnValueOnce(false); + installQueueUndoHotkey(); + const prevented = fireKey('z', { ctrlKey: true }); + expect(prevented).toBe(false); + }); + + it('skips when the target is an INPUT element (native text undo)', () => { + installQueueUndoHotkey(); + const input = document.createElement('input'); + document.body.appendChild(input); + try { + fireKey('z', { ctrlKey: true }, input); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + } finally { + input.remove(); + } + }); + + it('skips when the target is a TEXTAREA', () => { + installQueueUndoHotkey(); + const ta = document.createElement('textarea'); + document.body.appendChild(ta); + try { + fireKey('z', { ctrlKey: true }, ta); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + } finally { + ta.remove(); + } + }); + + it('does nothing for keys other than Z', () => { + installQueueUndoHotkey(); + fireKey('a', { ctrlKey: true }); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + }); + + it('does nothing without Ctrl / Cmd modifier', () => { + installQueueUndoHotkey(); + fireKey('z'); + expect(hoisted.undoMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/queueUndoHotkey.ts b/src/store/queueUndoHotkey.ts new file mode 100644 index 00000000..cf3cdc6f --- /dev/null +++ b/src/store/queueUndoHotkey.ts @@ -0,0 +1,62 @@ +import { getWindowKind } from '../app/windowKind'; +import { usePlayerStore } from './playerStore'; + +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; +} + +let installedHandler: ((e: KeyboardEvent) => void) | null = null; + +/** + * 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 bootstrap.ts); + * idempotent via the window-scoped flag. Skips the mini-player window so + * the host renderer owns queue history. + */ +export function installQueueUndoHotkey(): void { + if (typeof window === 'undefined') return; + const w = window as unknown as Record; + if (w[QUEUE_UNDO_HOTKEY_FLAG]) return; + if (getWindowKind() === 'mini') return; + w[QUEUE_UNDO_HOTKEY_FLAG] = true; + const handler = (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(); + } + }; + installedHandler = handler; + document.addEventListener('keydown', handler, true); +} + +/** Test-only: remove the installed listener and clear the install flag. */ +export function _resetQueueUndoHotkeyForTest(): void { + if (typeof window === 'undefined') return; + if (installedHandler) { + document.removeEventListener('keydown', installedHandler, true); + installedHandler = null; + } + const w = window as unknown as Record; + w[QUEUE_UNDO_HOTKEY_FLAG] = undefined; +} diff --git a/src/store/queueVisibilityStorage.test.ts b/src/store/queueVisibilityStorage.test.ts new file mode 100644 index 00000000..bb70ba2d --- /dev/null +++ b/src/store/queueVisibilityStorage.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + persistQueueVisibility, + readInitialQueueVisibility, +} from './queueVisibilityStorage'; + +const KEY = 'psysonic_queue_visible'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('readInitialQueueVisibility', () => { + it('defaults to true when no value is stored', () => { + expect(readInitialQueueVisibility()).toBe(true); + }); + + it('returns true for the "true" string', () => { + window.localStorage.setItem(KEY, 'true'); + expect(readInitialQueueVisibility()).toBe(true); + }); + + it('returns false for the "false" string', () => { + window.localStorage.setItem(KEY, 'false'); + expect(readInitialQueueVisibility()).toBe(false); + }); + + it('falls back to true on an unexpected stored value', () => { + window.localStorage.setItem(KEY, 'maybe'); + expect(readInitialQueueVisibility()).toBe(true); + }); +}); + +describe('persistQueueVisibility', () => { + it('stores "true" / "false" as strings', () => { + persistQueueVisibility(true); + expect(window.localStorage.getItem(KEY)).toBe('true'); + persistQueueVisibility(false); + expect(window.localStorage.getItem(KEY)).toBe('false'); + }); + + it('round-trips through readInitialQueueVisibility', () => { + persistQueueVisibility(false); + expect(readInitialQueueVisibility()).toBe(false); + persistQueueVisibility(true); + expect(readInitialQueueVisibility()).toBe(true); + }); +}); diff --git a/src/store/queueVisibilityStorage.ts b/src/store/queueVisibilityStorage.ts new file mode 100644 index 00000000..686370f5 --- /dev/null +++ b/src/store/queueVisibilityStorage.ts @@ -0,0 +1,32 @@ +/** + * Persists the QueuePanel visibility toggle to localStorage so it + * survives reloads. Defaults to true on the first run (no stored value) + * and on SSR / non-DOM contexts (`typeof window === 'undefined'`). + * + * Storage failures (quota, private-mode) are silently ignored — the UI + * keeps the in-memory state and the user just loses the persisted + * preference after the next restart. + */ + +const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible'; + +export 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; +} + +export 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 + } +}