import type { TrackRef, RadioResponse } from '$lib/api/types'; import { untrack } from 'svelte'; import { api } from '$lib/api/client'; import { user } from '$lib/auth/user.svelte'; import { sessionEnd } from '$lib/auth/sessionEnd.svelte'; import * as storage from '$lib/util/safeLocalStorage'; import { readPersistedQueue, writePersistedQueue, clearPersistedQueue } from './persisted'; export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; export type RepeatMode = 'off' | 'all' | 'one'; const VOLUME_KEY = 'minstrel.volume'; const CROSSFADE_KEY = 'minstrel.crossfade'; const CROSSFADE_MIN = 0; const CROSSFADE_MAX = 12; export function readStoredVolume(): number { const raw = storage.read(VOLUME_KEY); if (raw == null) return 1; const n = Number(raw); if (Number.isFinite(n) && n >= 0 && n <= 1) return n; return 1; } export function readStoredCrossfade(): number { const raw = storage.read(CROSSFADE_KEY); if (raw == null) return 0; const n = Number(raw); if (Number.isFinite(n) && n >= CROSSFADE_MIN && n <= CROSSFADE_MAX) { return Math.round(n); } return 0; } // Pure-function: maps (position, duration, crossfadeSec) → volume scalar // in [0,1]. Outside the crossfade windows it's 1. In the leading X // seconds: position/X (fade-in). In the trailing X seconds: // (duration-position)/X (fade-out). Tracks shorter than 2X don't fade. export function deriveFadeScalar( position: number, duration: number, crossfadeSec: number ): number { if (crossfadeSec <= 0 || duration <= 0) return 1; if (duration < crossfadeSec * 2) return 1; if (position < crossfadeSec) { return Math.max(0, Math.min(1, position / crossfadeSec)); } if (duration - position < crossfadeSec) { return Math.max(0, Math.min(1, (duration - position) / crossfadeSec)); } 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 _crossfadeSec = $state(readStoredCrossfade()); let _shuffle = $state(false); let _repeat = $state('off'); let _error = $state(null); let _queueDrawerOpen = $state(false); // C1: one-shot restore position — set by restoreQueue, consumed once by // the layout's loadedmetadata handler so the audio element seeks to the // saved position after metadata loads. let _pendingRestorePosition: number | null = null; // M4c: track when the queue was seeded by a radio call so we can // auto-refresh at 80% consumption. Cleared when the user enqueues // from a non-radio source. let _radioSeedId = $state(null); let _radioRefreshInFlight = false; // #415: which system playlist (if any) this queue was seeded from. // 'for_you' | 'discover' | null. Read by the events dispatcher so // play_started carries `source` and the play counts against that // playlist's rotation. Re-set on every playQueue (a fresh non-system // queue clears it). let _queueSource = $state(null); // Track B (#968): consecutive load failures since the last successful // 'playing'. A bad track auto-advances instead of dead-ending; the streak // (capped against queue length) stops a fully-unplayable queue from // looping forever. Reset on a successful play and on a fresh playQueue. let _failureStreak = 0; // Track B (#968): when a queue is seeded from a refreshable source (a system // playlist or radio), the seeder hands us a closure that re-pulls the fresh // snapshot. On total failure (whole queue unplayable — likely a stale tab // left open across the daily rebuild) we call it once to self-heal before // surfacing the dead-end. Kept opaque so the store stays decoupled from the // playlist API and the per-artist (songs_like_artist) identity problem. let _queueRefetch: (() => Promise) | null = null; let _selfHealInFlight = false; let _selfHealAttempts = 0; const MAX_SELF_HEAL_ATTEMPTS = 1; 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 crossfadeSec() { return _crossfadeSec; }, get shuffle() { return _shuffle; }, get repeat() { return _repeat; }, get error() { return _error; }, get queueSource() { return _queueSource; }, get queueDrawerOpen() { return _queueDrawerOpen; } }; export function registerAudioEl(el: HTMLAudioElement | null): void { _audioEl = el; } export function playQueue( tracks: TrackRef[], startIndex = 0, opts: { shuffle?: boolean; source?: string | null; refetch?: () => Promise; } = {}, ): void { _radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state // #415: a fresh queue resets the system-playlist source. Set only // when seeded from a system playlist (server already returned the // rotation-aware order, so no client shuffle in that path). _queueSource = opts.source ?? null; // #968: a fresh play replaces the self-heal closure + resets its budget. _queueRefetch = opts.refetch ?? null; _selfHealAttempts = 0; if (opts.shuffle && tracks.length > 1) { // Fisher-Yates over the whole list. startIndex is ignored — the // caller is asking for "random play from this pool," so the first // track should also be random, not the one at startIndex. const arr = tracks.slice(); for (let j = arr.length - 1; j > 0; j--) { const r = Math.floor(_rng() * (j + 1)); [arr[j], arr[r]] = [arr[r], arr[j]]; } _queue = arr; _index = 0; _shuffle = true; _state = 'loading'; } else { _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; _failureStreak = 0; } 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; storage.write(VOLUME_KEY, String(clamped)); } export function setCrossfade(sec: number): void { const clamped = Math.max(CROSSFADE_MIN, Math.min(CROSSFADE_MAX, Math.round(sec))); _crossfadeSec = clamped; storage.write(CROSSFADE_KEY, String(clamped)); } // Last non-zero volume captured so the M shortcut can toggle to silent // then restore. Set lazily on the first toggle and on any setVolume // that's non-zero. Default 0.5 keeps unmute usable even if the user // somehow lands at volume=0 with no prior non-zero value. let _volumeBeforeMute = 0.5; export function toggleMute(): void { if (_volume > 0) { _volumeBeforeMute = _volume; setVolume(0); } else { setVolume(_volumeBeforeMute > 0 ? _volumeBeforeMute : 0.5); } } 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; _failureStreak = 0; _selfHealAttempts = 0; return; case 'paused': _state = 'paused'; return; case 'waiting': _state = 'loading'; return; case 'error': handleLoadFailure(detail); 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; } } // Track B (#968): a single failed track must not strand the player on the // "Try again" dead-end. Advance past it; only surface the error once the // whole queue has proven unplayable — every track failed, or we reached the // end with nothing playable. The streak cap (vs queue length) stops a // fully-broken queue from cycling forever instead of settling. function handleLoadFailure(detail?: string): void { _failureStreak++; if (_failureStreak < _queue.length && _index + 1 < _queue.length) { _index++; _position = 0; _duration = 0; _state = 'loading'; _error = null; return; } // Whole queue is unplayable. If it came from a refreshable source, the // snapshot is probably stale — re-pull it and resume before giving up. if (trySelfHeal()) return; _state = 'error'; _error = detail ?? 'Playback failed.'; } // #968: re-pull a stale refreshable queue. Returns true if a self-heal was // started (caller must not dead-end). Bounded to one attempt per exhaustion // (reset on the next successful play) so a still-broken refresh can't loop. function trySelfHeal(): boolean { if (_selfHealInFlight) return true; if (_queueRefetch === null) return false; if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false; _selfHealAttempts++; _selfHealInFlight = true; _state = 'loading'; _error = null; const refetch = _queueRefetch; void refetch() .then((refs) => { if (refs.length > 0) { // Re-seed in place — keep _queueSource / _queueRefetch so the new // snapshot can itself self-heal, and don't reset _selfHealAttempts // (only a successful 'playing' clears the budget). _queue = refs; _index = 0; _position = 0; _duration = 0; _failureStreak = 0; _state = 'loading'; _error = null; } else { _state = 'error'; _error = 'Nothing playable in this mix right now.'; } }) .catch(() => { _state = 'error'; _error = 'Couldn’t refresh this mix. Try again.'; }) .finally(() => { _selfHealInFlight = false; }); return true; } export function enqueueTrack(t: TrackRef): void { _radioSeedId = null; // M4c _queue = [..._queue, t]; if (_state === 'idle') _index = 0; } /** * M7 #372: insert a track immediately after the currently-playing one * so it plays next. If the queue is empty, behaves like enqueueTrack * and starts the queue with this track at index 0. */ export function playNext(t: TrackRef): void { _radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state if (_queue.length === 0) { _queue = [t]; _index = 0; return; } const next = _index + 1; _queue = [..._queue.slice(0, next), t, ..._queue.slice(next)]; } export function enqueueTracks(ts: TrackRef[]): void { _radioSeedId = null; // M4c if (ts.length === 0) return; _queue = [..._queue, ...ts]; if (_state === 'idle') _index = 0; } // Bulk variant of playNext: inserts every track immediately after the // currently-playing one, preserving order. Empty queue seeds the queue // with the batch at index 0 (mirrors playNext's single-track shape). export function playNextMany(ts: TrackRef[]): void { _radioSeedId = null; if (ts.length === 0) return; if (_queue.length === 0) { _queue = [...ts]; _index = 0; return; } const next = _index + 1; _queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)]; } async function fetchRadioTracks(seedTrackId: string): Promise { const resp = await api.get( `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` ); return resp.tracks; } export async function playRadio(seedTrackId: string): Promise { const tracks = await fetchRadioTracks(seedTrackId); if (tracks.length === 0) return; // #968: hand the player a self-heal closure so a fully-stale radio queue // re-seeds from the same track instead of dead-ending. playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) }); _radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it) } export function moveQueueItem(from: number, to: number): void { if (from === to) return; if (from < 0 || from >= _queue.length) return; if (to < 0 || to >= _queue.length) return; const playingId = _queue[_index]?.id; const next = _queue.slice(); const [moved] = next.splice(from, 1); next.splice(to, 0, moved); _queue = next; if (playingId) { const found = next.findIndex((x) => x.id === playingId); if (found >= 0) _index = found; } } export function removeFromQueue(idx: number): void { if (idx < 0 || idx >= _queue.length) return; const next = _queue.slice(); next.splice(idx, 1); if (idx < _index) { _queue = next; _index = _index - 1; return; } if (idx > _index) { _queue = next; return; } // idx === _index — removing the currently-playing track. _queue = next; if (next.length === 0) { _index = 0; _state = 'idle'; _position = 0; _duration = 0; _error = null; return; } if (_index >= next.length) { // Removed the last track. Mirror skipNext end-of-queue behaviour: // pause on the now-last remaining track at position 0 — don't // auto-play a track the user already heard. _index = next.length - 1; _state = 'paused'; _position = 0; _duration = 0; _error = null; return; } // _index stays — now points at what was the next track. Advance into it. _position = 0; _duration = 0; _state = 'loading'; _error = null; } export function playFromQueueIndex(idx: number): void { if (idx < 0 || idx >= _queue.length) return; _radioSeedId = null; _index = idx; _position = 0; _duration = 0; _state = 'loading'; _error = null; } export function toggleQueueDrawer(): void { _queueDrawerOpen = !_queueDrawerOpen; } export function closeQueueDrawer(): void { _queueDrawerOpen = false; } export function restoreQueue(userId: string): void { if (!userId) return; const persisted = readPersistedQueue(userId); if (!persisted) return; if (persisted.queue.length === 0) return; _queue = persisted.queue; _index = Math.max(0, Math.min(persisted.index, persisted.queue.length - 1)); _position = persisted.position; _pendingRestorePosition = persisted.position; _duration = 0; _state = 'paused'; _error = null; _radioSeedId = null; } export function consumePendingRestorePosition(): number | null { const p = _pendingRestorePosition; _pendingRestorePosition = null; return p; } // M4c: auto-refresh radio queue when 80% consumed. // Fires whenever the consumption ratio crosses 80% AND the queue was // seeded by playRadio() AND no refresh is currently in-flight. $effect.root(() => { $effect(() => { if (_radioSeedId === null) return; if (_radioRefreshInFlight) return; if (_queue.length === 0) return; const consumedRatio = (_index + 1) / _queue.length; if (consumedRatio < 0.8) return; _radioRefreshInFlight = true; const seed = _radioSeedId; // Drift #554: cap the exclude list so a multi-hour radio session // doesn't grow the query string past common 8KB limits and start // 414-ing /api/radio. The .catch() below would silently swallow // that failure and the player would stop topping up — a dead // radio. The server's RecentlyPlayedHours filter already handles // broader history dedup, so the request-side exclude only needs // to cover the visible queue's recent tail. const RADIO_EXCLUDE_CAP = 100; const exclude = _queue .slice(-RADIO_EXCLUDE_CAP) .map((t) => t.id) .join(','); api .get( `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}` ) .then((resp) => { // Strip the seed at index 0 (already in our queue) and any other // tracks that happen to match the original seed id. const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); if (newTracks.length > 0) { // Append directly without calling enqueueTracks — that would // clear _radioSeedId and prevent further refreshes. _queue = [..._queue, ...newTracks]; } }) .catch(() => { // Swallow; next track-advance can retry. }) .finally(() => { _radioRefreshInFlight = false; }); }); }); // Session-end teardown (#375): auth signals via sessionEnd; player // owns the player-specific cleanup (clear persisted queue for the // outgoing user, reset in-memory queue, close drawer) without auth // having to import any player APIs. $effect.root(() => { let lastTick = sessionEnd.tick; $effect(() => { if (sessionEnd.tick === lastTick) return; lastTick = sessionEnd.tick; const prevId = sessionEnd.userId; if (prevId) clearPersistedQueue(prevId); playQueue([]); closeQueueDrawer(); }); }); // Persistence (M7 #364): split-effect shape. Queue/index changes write // immediately (rare, user-driven); position changes throttle to once // per 5s during steady playback. See spec section "Persistence write // effect" for the alternative debounce shape. $effect.root(() => { let lastPositionWriteAt = 0; // Immediate write on queue or index changes. Position is read via // untrack so this effect doesn't fire on every position tick — only // queue/index changes drive it. The throttled-write effect below // covers position drift. $effect(() => { const userId = user.value?.id; if (!userId) return; // Reading these registers the reactive dependency; the values // themselves are unused — writePersistedQueue re-reads live state. _queue; _index; writePersistedQueue(userId, { queue: _queue, index: _index, position: untrack(() => _position) }); lastPositionWriteAt = Date.now(); }); // Throttled write on position changes. $effect(() => { const userId = user.value?.id; if (!userId) return; const _p = _position; void _p; const now = Date.now(); if (now - lastPositionWriteAt < 5000) return; writePersistedQueue(userId, { queue: untrack(() => _queue), index: untrack(() => _index), position: _position }); lastPositionWriteAt = now; }); });