Files
minstrel/web/src/lib/player/store.svelte.ts
T
bvandeusen 7dd77a4723 feat(web): add enqueueTrack, enqueueTracks, playRadio to player store
enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone.
2026-04-25 15:22:03 -04:00

218 lines
5.5 KiB
TypeScript

import type { TrackRef, RadioResponse } from '$lib/api/types';
import { api } from '$lib/api/client';
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';
return;
}
// At end of queue.
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// 'off' or 'one' both pause at end on EXPLICIT skip.
_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;
}
// --- Shuffle (with injectable RNG for tests) ---
let _rng: () => number = Math.random;
export function __setShuffleRng(fn: () => number): void { _rng = fn; }
export function toggleShuffle(): void {
_shuffle = !_shuffle;
if (_shuffle && _index + 1 < _queue.length) {
// Fisher-Yates shuffle over _queue[_index+1 .. end].
const arr = _queue.slice();
for (let j = arr.length - 1; j > _index; j--) {
const r = Math.floor(_rng() * (j - _index)) + _index + 1;
[arr[j], arr[r]] = [arr[r], arr[j]];
}
_queue = arr;
}
}
// --- Repeat ---
export function cycleRepeat(): void {
_repeat = _repeat === 'off' ? 'all' : _repeat === 'all' ? 'one' : 'off';
}
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void {
switch (event) {
case 'playing':
_state = 'playing';
_error = null;
return;
case 'paused':
_state = 'paused';
return;
case 'waiting':
_state = 'loading';
return;
case 'error':
_state = 'error';
_error = detail ?? 'Playback failed.';
return;
case 'ended':
if (_repeat === 'one') {
_position = 0;
_state = 'loading';
if (_audioEl) _audioEl.currentTime = 0;
return;
}
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
return;
}
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
_state = 'paused';
_position = _duration;
}
return;
}
}
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}