diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 42650296..ef8f9c1d 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -83,6 +83,12 @@ let _radioRefreshInFlight = false; // 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; + let _audioEl: HTMLAudioElement | null = null; export const player = { @@ -142,6 +148,7 @@ export function playQueue( _position = 0; _duration = 0; _error = null; + _failureStreak = 0; } export function togglePlay(): void { @@ -263,6 +270,7 @@ export function reportStateFromAudio( case 'playing': _state = 'playing'; _error = null; + _failureStreak = 0; return; case 'paused': _state = 'paused'; @@ -271,8 +279,7 @@ export function reportStateFromAudio( _state = 'loading'; return; case 'error': - _state = 'error'; - _error = detail ?? 'Playback failed.'; + handleLoadFailure(detail); return; case 'ended': if (_repeat === 'one') { @@ -301,6 +308,25 @@ export function reportStateFromAudio( } } +// 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; + } + _state = 'error'; + _error = detail ?? 'Playback failed.'; +} + export function enqueueTrack(t: TrackRef): void { _radioSeedId = null; // M4c _queue = [..._queue, t]; diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index f4c144fe..2b5228f1 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -288,6 +288,24 @@ describe('player store — shuffle + repeat + audio reports', () => { expect(player.state).toBe('error'); expect(player.error).toBe('network lost'); }); + + test('reportStateFromAudio("error") auto-advances past a bad track', () => { + playQueue([track('1'), track('2'), track('3')]); + reportStateFromAudio('error', 'boom'); + expect(player.index).toBe(1); + expect(player.state).toBe('loading'); + expect(player.error).toBeNull(); + }); + + test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => { + playQueue([track('1'), track('2')]); + reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2 + expect(player.index).toBe(1); + expect(player.state).toBe('loading'); + reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted + expect(player.state).toBe('error'); + expect(player.error).toBe('boom'); + }); }); import {