fix(web/player): auto-skip a failed track instead of dead-ending on "Try again"
test-web / test (push) Successful in 39s

A track that fails to load (e.g. a stale system-playlist snapshot pointing
at a rebuilt/removed file) hard-set the player to the 'error' state and
stranded the user on a "Try again" button that just re-queued the same
failing track. Now a load error advances to the next track; the error
state only surfaces once the whole queue has proven unplayable — every
track failed, or we reached the end. A failure streak capped at queue
length stops a fully-broken queue from cycling, and resets on the next
successful play.

Next (Track B cont.): self-heal a stale system-playlist / radio queue by
re-pulling the fresh snapshot on total failure, plus the Android
equivalent. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:40:08 -04:00
parent 096a3c0b15
commit 2a8de82a17
2 changed files with 46 additions and 2 deletions
+28 -2
View File
@@ -83,6 +83,12 @@ let _radioRefreshInFlight = false;
// 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;
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];
+18
View File
@@ -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 {