feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
Phases 1+2 of the background-play resilience work. Net effect on desktop: gap-free track transitions on slow networks, automatic recovery from transient stalls / network errors mid-track. - preload="metadata" → "auto" on the main audio element so the browser pre-buffers more aggressively. - attachStallRetry() — listens for stalled / waiting / network-error events. Schedules a reload-from-current-position after delayMs (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK triggers immediate retry. Bounded by maxRetries (3 default) to prevent tight loops on persistent failures. - audioLoader seam — `lib/player/audioLoader.ts` exposes a resolve(track) → URL + optional prefetch(track) hint. Default returns track.stream_url; the planned Tauri shell can swap via setAudioLoader() to return blob URLs backed by a Rust cache, mirroring the Flutter drift+LockCachingAudioSource pattern. - Hidden prefetch <audio> element. When current track passes 50%, start loading queue[index+1]. On track-end the swap is gap-free via browser HTTP cache + audio buffer warm-up. Phase 3 (service worker chunk cache) deliberately deferred — that work belongs in the Tauri shell as native Rust caching, not as a web-only investment that would compete with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { attachStallRetry } from './stallRetry';
|
||||
|
||||
// Minimal HTMLAudioElement stand-in for vitest. Real DOM elements
|
||||
// are over-featured; we only need the methods + addEventListener.
|
||||
function makeAudioStub() {
|
||||
const handlers = new Map<string, Set<EventListener>>();
|
||||
const stub = {
|
||||
currentTime: 0,
|
||||
paused: false,
|
||||
readyState: 4,
|
||||
error: null as MediaError | null,
|
||||
load: vi.fn(),
|
||||
play: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener(type: string, fn: EventListener) {
|
||||
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||
handlers.get(type)!.add(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: EventListener) {
|
||||
handlers.get(type)?.delete(fn);
|
||||
},
|
||||
fire(type: string) {
|
||||
handlers.get(type)?.forEach((fn) => fn(new Event(type)));
|
||||
}
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe('attachStallRetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('stalled event triggers reload after delayMs when buffer not recovered', () => {
|
||||
const el = makeAudioStub();
|
||||
el.currentTime = 42;
|
||||
el.readyState = 1; // not yet HAVE_FUTURE_DATA
|
||||
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
|
||||
|
||||
el.fire('stalled');
|
||||
expect(el.load).not.toHaveBeenCalled(); // not yet — waiting
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(el.load).toHaveBeenCalledTimes(1);
|
||||
expect(el.currentTime).toBe(42); // restored after load()
|
||||
expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false)
|
||||
});
|
||||
|
||||
test('skips reload when readyState recovered before delay elapses', () => {
|
||||
const el = makeAudioStub();
|
||||
el.readyState = 1;
|
||||
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
|
||||
|
||||
el.fire('stalled');
|
||||
el.readyState = 4; // recovered
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(el.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('canplay clears retry counter so a later stall can retry again', () => {
|
||||
const el = makeAudioStub();
|
||||
el.readyState = 1;
|
||||
attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||
delayMs: 100,
|
||||
maxRetries: 1
|
||||
});
|
||||
|
||||
el.fire('stalled');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(el.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
el.fire('canplay'); // resets retry count
|
||||
el.fire('stalled');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(el.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('maxRetries caps recovery attempts', () => {
|
||||
const el = makeAudioStub();
|
||||
el.readyState = 1;
|
||||
attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||
delayMs: 100,
|
||||
maxRetries: 2
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
el.fire('stalled');
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
expect(el.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('MEDIA_ERR_NETWORK error triggers immediate reload', () => {
|
||||
const el = makeAudioStub();
|
||||
el.error = { code: 2 } as MediaError;
|
||||
el.currentTime = 17;
|
||||
attachStallRetry(el as unknown as HTMLAudioElement);
|
||||
|
||||
el.fire('error');
|
||||
expect(el.load).toHaveBeenCalled();
|
||||
expect(el.currentTime).toBe(17);
|
||||
});
|
||||
|
||||
test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => {
|
||||
const el = makeAudioStub();
|
||||
el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE
|
||||
attachStallRetry(el as unknown as HTMLAudioElement);
|
||||
|
||||
el.fire('error');
|
||||
expect(el.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('teardown removes listeners and clears pending timer', () => {
|
||||
const el = makeAudioStub();
|
||||
el.readyState = 1;
|
||||
const teardown = attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||
delayMs: 100
|
||||
});
|
||||
|
||||
el.fire('stalled');
|
||||
teardown();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(el.load).not.toHaveBeenCalled();
|
||||
|
||||
// After teardown, firing events does nothing.
|
||||
el.fire('stalled');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(el.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user