7bc254e17e
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>
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
});
|