Files
minstrel/web/src/lib/player/store.svelte.ts
T
bvandeusen 27766ae063
test-web / test (push) Successful in 39s
feat(web/player): self-heal a stale system-playlist / radio queue on total failure
When the whole queue proves unplayable (e.g. a tab left open across the
daily system-playlist rebuild — the exact stale-snapshot case), the player
now re-pulls the fresh snapshot and resumes instead of dead-ending on
"Try again". The seeder hands the store an opaque refetch closure so the
store stays decoupled from the playlist API and the per-artist
(songs_like_artist) identity problem: single-instance variants re-pull via
systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from
its track. Bounded to one self-heal per exhaustion (reset on the next
successful play) so a still-broken refresh can't loop; "Try again" stays
the genuine last resort. Wired from PlaylistCard, the playlist detail page,
and playRadio. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:51:47 -04:00

653 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _crossfadeSec = $state(readStoredCrossfade());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(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<string | null>(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<string | null>(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<TrackRef[]>) | 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<TrackRef[]>;
} = {},
): 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 = 'Couldnt 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<TrackRef[]> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
return resp.tracks;
}
export async function playRadio(seedTrackId: string): Promise<void> {
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<RadioResponse>(
`/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;
});
});