fix(web/player): auto-skip a failed track instead of dead-ending on "Try again"
test-web / test (push) Successful in 39s
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:
@@ -83,6 +83,12 @@ let _radioRefreshInFlight = false;
|
|||||||
// queue clears it).
|
// queue clears it).
|
||||||
let _queueSource = $state<string | null>(null);
|
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;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -142,6 +148,7 @@ export function playQueue(
|
|||||||
_position = 0;
|
_position = 0;
|
||||||
_duration = 0;
|
_duration = 0;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePlay(): void {
|
export function togglePlay(): void {
|
||||||
@@ -263,6 +270,7 @@ export function reportStateFromAudio(
|
|||||||
case 'playing':
|
case 'playing':
|
||||||
_state = 'playing';
|
_state = 'playing';
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
return;
|
return;
|
||||||
case 'paused':
|
case 'paused':
|
||||||
_state = 'paused';
|
_state = 'paused';
|
||||||
@@ -271,8 +279,7 @@ export function reportStateFromAudio(
|
|||||||
_state = 'loading';
|
_state = 'loading';
|
||||||
return;
|
return;
|
||||||
case 'error':
|
case 'error':
|
||||||
_state = 'error';
|
handleLoadFailure(detail);
|
||||||
_error = detail ?? 'Playback failed.';
|
|
||||||
return;
|
return;
|
||||||
case 'ended':
|
case 'ended':
|
||||||
if (_repeat === 'one') {
|
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 {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
_radioSeedId = null; // M4c
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
|
|||||||
@@ -288,6 +288,24 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
|||||||
expect(player.state).toBe('error');
|
expect(player.state).toBe('error');
|
||||||
expect(player.error).toBe('network lost');
|
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 {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user