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([]); 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'; 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 { const resp = await api.get( `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` ); if (resp.tracks.length > 0) playQueue(resp.tracks, 0); }