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:
2026-05-10 17:43:41 -04:00
co-authored by Claude Opus 4.7
parent 1daf0b7fdf
commit 7bc254e17e
5 changed files with 349 additions and 2 deletions
+41
View File
@@ -0,0 +1,41 @@
// Audio loader seam. Today: returns the server-relative stream URL
// directly. In the future Tauri shell, the loader can be swapped to
// return blob URLs backed by a Rust-side cache (mirroring the Flutter
// drift+LockCachingAudioSource pattern), without touching the player UI.
//
// Two operations:
// - resolve(track) — returns the URL the <audio> element should src.
// - prefetch(track) — hint that a track will be needed soon. Default
// no-op (the browser handles it via the prefetch <audio> element);
// Tauri can warm its native cache here.
import type { TrackRef } from '$lib/api/types';
export type AudioLoader = {
resolve: (track: TrackRef) => string;
prefetch?: (track: TrackRef) => void;
};
const defaultLoader: AudioLoader = {
resolve: (track) => track.stream_url
};
let _current: AudioLoader = defaultLoader;
export const audioLoader: AudioLoader = {
resolve: (track) => _current.resolve(track),
prefetch: (track) => _current.prefetch?.(track)
};
/**
* Replace the active loader. Call once at startup from the Tauri shell;
* web shell uses the default and never calls this.
*/
export function setAudioLoader(loader: AudioLoader): void {
_current = loader;
}
/** Test-only: restore the default loader between tests. */
export function __resetAudioLoader(): void {
_current = defaultLoader;
}