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,52 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { audioLoader, setAudioLoader, __resetAudioLoader } from './audioLoader';
|
||||
|
||||
const sampleTrack: TrackRef = {
|
||||
id: 't1',
|
||||
title: 'Sample',
|
||||
album_id: 'a1',
|
||||
album_title: 'Album',
|
||||
artist_id: 'ar1',
|
||||
artist_name: 'Artist',
|
||||
duration_sec: 180,
|
||||
stream_url: '/api/tracks/t1/stream'
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
__resetAudioLoader();
|
||||
});
|
||||
|
||||
describe('audioLoader', () => {
|
||||
test('default resolve returns the track stream_url', () => {
|
||||
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
|
||||
});
|
||||
|
||||
test('default prefetch is a no-op (does not throw)', () => {
|
||||
expect(() => audioLoader.prefetch?.(sampleTrack)).not.toThrow();
|
||||
});
|
||||
|
||||
test('setAudioLoader swaps the resolver', () => {
|
||||
setAudioLoader({
|
||||
resolve: (t) => `blob:cached-${t.id}`
|
||||
});
|
||||
expect(audioLoader.resolve(sampleTrack)).toBe('blob:cached-t1');
|
||||
});
|
||||
|
||||
test('setAudioLoader swaps the prefetch hook', () => {
|
||||
const spy = vi.fn();
|
||||
setAudioLoader({
|
||||
resolve: (t) => t.stream_url,
|
||||
prefetch: spy
|
||||
});
|
||||
audioLoader.prefetch?.(sampleTrack);
|
||||
expect(spy).toHaveBeenCalledWith(sampleTrack);
|
||||
});
|
||||
|
||||
test('__resetAudioLoader restores the default', () => {
|
||||
setAudioLoader({ resolve: () => 'blob:swapped' });
|
||||
expect(audioLoader.resolve(sampleTrack)).toBe('blob:swapped');
|
||||
__resetAudioLoader();
|
||||
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user