7dd77a4723
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.
218 lines
5.5 KiB
TypeScript
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);
|
|
}
|