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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user