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.
This commit is contained in:
2026-04-24 17:54:07 -04:00
parent 7c19fe8a6a
commit 431f333e41
3 changed files with 298 additions and 0 deletions
+120
View File
@@ -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<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(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;
}
+156
View File
@@ -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);
});
});
+22
View File
@@ -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<string, string>();
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 });
}