mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
013d6144ca
commit
b0418bf920
@@ -120,11 +120,20 @@ import {
|
|||||||
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
||||||
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
||||||
import { initAudioListeners } from './initAudioListeners';
|
import { initAudioListeners } from './initAudioListeners';
|
||||||
|
import { installQueueUndoHotkey } from './queueUndoHotkey';
|
||||||
|
import {
|
||||||
|
persistQueueVisibility,
|
||||||
|
readInitialQueueVisibility,
|
||||||
|
} from './queueVisibilityStorage';
|
||||||
|
|
||||||
// Re-export so MainApp + the 3 playerStore characterization tests keep
|
// Re-export so MainApp + the 3 playerStore characterization tests keep
|
||||||
// their existing `from './playerStore'` imports.
|
// their existing `from './playerStore'` imports.
|
||||||
export { initAudioListeners };
|
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
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
export { flushPlayQueuePosition };
|
export { flushPlayQueuePosition };
|
||||||
@@ -138,7 +147,6 @@ export {
|
|||||||
subscribePlaybackProgress,
|
subscribePlaybackProgress,
|
||||||
type PlaybackProgressSnapshot,
|
type PlaybackProgressSnapshot,
|
||||||
};
|
};
|
||||||
import { getWindowKind } from '../app/windowKind';
|
|
||||||
import {
|
import {
|
||||||
_resetQueueUndoStacksForTest,
|
_resetQueueUndoStacksForTest,
|
||||||
consumePendingQueueListScrollTop,
|
consumePendingQueueListScrollTop,
|
||||||
@@ -166,29 +174,6 @@ export {
|
|||||||
registerQueueListScrollTopReader,
|
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 {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
title: 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<KeyboardEventInit> = {}, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
w[QUEUE_UNDO_HOTKEY_FLAG] = undefined;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user