From 431f333e415e8ee1eea880240fc87f4afb0ee528 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 24 Apr 2026 17:54:07 -0400 Subject: [PATCH] feat(web): add player rune store with basic actions Core state (queue, index, state, position, duration, volume, shuffle, repeat, error) plus actions: playQueue, togglePlay, linear skipNext, skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted), registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat logic and audio-event reporting land in follow-up commits. Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25 exposes a partial localStorage global that lacks getItem/setItem/clear (requires --localstorage-file with a path), and jsdom defers to the Node global when present, leaving tests unable to exercise storage. --- web/src/lib/player/store.svelte.ts | 120 ++++++++++++++++++++++ web/src/lib/player/store.test.ts | 156 +++++++++++++++++++++++++++++ web/vitest.setup.ts | 22 ++++ 3 files changed, 298 insertions(+) create mode 100644 web/src/lib/player/store.svelte.ts create mode 100644 web/src/lib/player/store.test.ts diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts new file mode 100644 index 00000000..5fb92ff6 --- /dev/null +++ b/web/src/lib/player/store.svelte.ts @@ -0,0 +1,120 @@ +import type { TrackRef } from '$lib/api/types'; + +export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; +export type RepeatMode = 'off' | 'all' | 'one'; + +const VOLUME_KEY = 'minstrel.volume'; + +export function readStoredVolume(): number { + try { + const raw = localStorage.getItem(VOLUME_KEY); + if (raw == null) return 1; + const n = Number(raw); + if (Number.isFinite(n) && n >= 0 && n <= 1) return n; + return 1; + } catch { + return 1; + } +} + +let _queue = $state([]); +let _index = $state(0); +let _state = $state('idle'); +let _position = $state(0); +let _duration = $state(0); +let _volume = $state(readStoredVolume()); +let _shuffle = $state(false); +let _repeat = $state('off'); +let _error = $state(null); + +let _audioEl: HTMLAudioElement | null = null; + +export const player = { + get queue() { return _queue; }, + get index() { return _index; }, + get current() { return _queue[_index] as TrackRef | undefined; }, + get state() { return _state; }, + get isPlaying() { return _state === 'playing'; }, + get position() { return _position; }, + get duration() { return _duration; }, + get volume() { return _volume; }, + get shuffle() { return _shuffle; }, + get repeat() { return _repeat; }, + get error() { return _error; } +}; + +export function registerAudioEl(el: HTMLAudioElement | null): void { + _audioEl = el; +} + +export function playQueue(tracks: TrackRef[], startIndex = 0): void { + _queue = tracks; + if (tracks.length === 0) { + _index = 0; + _state = 'idle'; + } else { + _index = Math.max(0, Math.min(startIndex, tracks.length - 1)); + _state = 'loading'; + } + _position = 0; + _duration = 0; + _error = null; +} + +export function togglePlay(): void { + if (_queue.length === 0) return; + // "Intent to play" (loading + playing) → paused. Anything else → loading (user + // wants to play; audio will catch up). + if (_state === 'playing' || _state === 'loading') { + _state = 'paused'; + } else { + _state = 'loading'; + } +} + +export function skipNext(): void { + if (_queue.length === 0) return; + if (_index + 1 < _queue.length) { + _index++; + _position = 0; + _duration = 0; + _state = 'loading'; + } else { + // At end: no-repeat-aware logic yet (Task 2 extends this). + _state = 'paused'; + _position = _duration; + } +} + +export function skipPrev(): void { + if (_queue.length === 0) return; + if (_position < 3 && _index > 0) { + _index--; + _position = 0; + _duration = 0; + _state = 'loading'; + } else { + // Seek to 0 of the current track. + _position = 0; + if (_audioEl) _audioEl.currentTime = 0; + } +} + +export function seekTo(sec: number): void { + _position = sec; + if (_audioEl) _audioEl.currentTime = sec; +} + +export function setVolume(v: number): void { + const clamped = Math.max(0, Math.min(1, v)); + _volume = clamped; + try { localStorage.setItem(VOLUME_KEY, String(clamped)); } catch { /* ignore */ } +} + +export function reportTimeUpdate(sec: number): void { + _position = sec; +} + +export function reportDuration(sec: number): void { + _duration = Number.isFinite(sec) ? sec : 0; +} diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts new file mode 100644 index 00000000..ac95203c --- /dev/null +++ b/web/src/lib/player/store.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + player, + playQueue, + togglePlay, + skipNext, + skipPrev, + seekTo, + setVolume, + registerAudioEl, + reportTimeUpdate, + reportDuration, + readStoredVolume +} from './store.svelte'; +import type { TrackRef } from '$lib/api/types'; + +function track(id: string, dur = 180): TrackRef { + return { + id, title: `T ${id}`, + album_id: 'a1', album_title: 'A', + artist_id: 'ar1', artist_name: 'Ar', + track_number: Number(id), disc_number: 1, + duration_sec: dur, + stream_url: `/api/tracks/${id}/stream` + }; +} + +beforeEach(() => { + localStorage.clear(); + // Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0). + playQueue([]); + setVolume(1); + registerAudioEl(null); +}); + +describe('player store — basic actions', () => { + test('playQueue sets queue, index, state="loading", position=0', () => { + playQueue([track('1'), track('2'), track('3')], 1); + expect(player.queue.length).toBe(3); + expect(player.index).toBe(1); + expect(player.state).toBe('loading'); + expect(player.position).toBe(0); + expect(player.current?.id).toBe('2'); + }); + + test('playQueue clamps startIndex to [0, length-1]', () => { + playQueue([track('1'), track('2')], 99); + expect(player.index).toBe(1); + playQueue([track('1'), track('2')], -5); + expect(player.index).toBe(0); + }); + + test('togglePlay flips paused<->playing; no-op on idle', () => { + expect(player.state).toBe('idle'); + togglePlay(); + expect(player.state).toBe('idle'); // still idle with empty queue + + playQueue([track('1')]); + // After playQueue we're in 'loading'; togglePlay should not advance without an audio event. + // But togglePlay from 'loading' should set the intent to play anyway — tested via report later. + // Here, simulate the paused state: + reportTimeUpdate(0); + // manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2. + // Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play). + togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent + expect(player.state).toBe('paused'); + togglePlay(); // from 'paused' — moves to 'loading' (re-play intent) + expect(player.state).toBe('loading'); + }); + + test('skipNext mid-queue: index++, position=0', () => { + playQueue([track('1'), track('2'), track('3')], 0); + reportTimeUpdate(50); + skipNext(); + expect(player.index).toBe(1); + expect(player.position).toBe(0); + }); + + test('skipNext at end with no repeat: index stays, state=paused, position=duration', () => { + playQueue([track('1'), track('2')], 1); + reportDuration(180); + skipNext(); + expect(player.index).toBe(1); + expect(player.state).toBe('paused'); + expect(player.position).toBe(180); + }); + + test('skipPrev with position<3 AND index>0: decrements index', () => { + playQueue([track('1'), track('2'), track('3')], 2); + reportTimeUpdate(2); + skipPrev(); + expect(player.index).toBe(1); + expect(player.position).toBe(0); + }); + + test('skipPrev with position>=3: seeks to 0, index unchanged', () => { + playQueue([track('1'), track('2')], 1); + reportTimeUpdate(10); + skipPrev(); + expect(player.index).toBe(1); + expect(player.position).toBe(0); + }); + + test('skipPrev at index=0: position=0, index stays', () => { + playQueue([track('1'), track('2')], 0); + reportTimeUpdate(2); + skipPrev(); + expect(player.index).toBe(0); + expect(player.position).toBe(0); + }); + + test('setVolume clamps to [0,1] and persists to localStorage', () => { + setVolume(0.5); + expect(player.volume).toBe(0.5); + expect(localStorage.getItem('minstrel.volume')).toBe('0.5'); + + setVolume(-1); + expect(player.volume).toBe(0); + + setVolume(2); + expect(player.volume).toBe(1); + }); + + test('readStoredVolume returns 1.0 when missing or invalid', () => { + localStorage.clear(); + expect(readStoredVolume()).toBe(1); + localStorage.setItem('minstrel.volume', 'not-a-number'); + expect(readStoredVolume()).toBe(1); + localStorage.setItem('minstrel.volume', '-10'); + expect(readStoredVolume()).toBe(1); // out of range → fallback + localStorage.setItem('minstrel.volume', '0.7'); + expect(readStoredVolume()).toBe(0.7); + }); + + test('seekTo writes position and, when registered, audioEl.currentTime', () => { + const el = { currentTime: 0 } as unknown as HTMLAudioElement; + registerAudioEl(el); + playQueue([track('1')]); + seekTo(42); + expect(player.position).toBe(42); + expect(el.currentTime).toBe(42); + + // After unregister, seekTo still updates store but doesn't throw. + registerAudioEl(null); + seekTo(10); + expect(player.position).toBe(10); + }); + + test('reportTimeUpdate and reportDuration mutate position and duration', () => { + playQueue([track('1')]); + reportTimeUpdate(15); + reportDuration(240); + expect(player.position).toBe(15); + expect(player.duration).toBe(240); + }); +}); diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index bb02c60c..8ffcfdd9 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -1 +1,23 @@ import '@testing-library/jest-dom/vitest'; + +// Node 25 ships a partial localStorage global that lacks getItem/setItem/clear +// unless launched with --localstorage-file. jsdom sees Node's global and skips +// installing its own. Replace with an in-memory Storage-compatible polyfill so +// tests (and browser-only code under test) see a working localStorage. +class MemoryStorage implements Storage { + private store = new Map(); + get length() { return this.store.size; } + clear(): void { this.store.clear(); } + getItem(key: string): string | null { return this.store.get(key) ?? null; } + key(i: number): string | null { return Array.from(this.store.keys())[i] ?? null; } + removeItem(key: string): void { this.store.delete(key); } + setItem(key: string, value: string): void { this.store.set(key, String(value)); } +} +const memLocal = new MemoryStorage(); +const memSession = new MemoryStorage(); +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: memLocal }); +Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: memSession }); +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { configurable: true, value: memLocal }); + Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession }); +}