fix(audio): fresh media element per song to avoid AbortError on switch

When WaveSurfer.destroy() is called it aborts its internal fetch AbortController.
If the same HTMLAudioElement is immediately passed to a new WaveSurfer instance,
the aborted signal is still draining — the new instance's loadAudio call sees it
and throws AbortError: signal is aborted without reason.

Fix: create a new <audio> element for every new song via createMediaElement().
destroyWaveSurfer() removes and discards the old element (inside the existing
try/catch so jsdom test noise is suppressed). The new element is still appended
to document.body so playback survives SongPage unmounts.

resetInstance() now delegates to cleanup() to properly tear down the media
element between tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-08 21:17:43 +02:00
parent d08eebf0eb
commit 8b7415954c

View File

@@ -24,19 +24,20 @@ class AudioService {
// For use in tests only // For use in tests only
public static resetInstance(): void { public static resetInstance(): void {
if (this.instance?.mediaElement) { this.instance?.cleanup();
this.instance.mediaElement.remove();
}
this.instance = undefined as any; this.instance = undefined as any;
} }
private getOrCreateMediaElement(): HTMLAudioElement { private createMediaElement(): HTMLAudioElement {
if (!this.mediaElement) { // Always create a fresh element — never reuse the one from a destroyed
this.mediaElement = document.createElement('audio'); // WaveSurfer instance. WaveSurfer.destroy() aborts its internal fetch
this.mediaElement.style.display = 'none'; // signal, which can poison the same element when the next instance tries
document.body.appendChild(this.mediaElement); // to load a new URL. A new element has no lingering aborted state.
} // The element is appended to document.body so it outlives SongPage unmounts.
return this.mediaElement; const el = document.createElement('audio');
el.style.display = 'none';
document.body.appendChild(el);
return el;
} }
public async initialize(container: HTMLElement, url: string): Promise<void> { public async initialize(container: HTMLElement, url: string): Promise<void> {
@@ -52,11 +53,13 @@ class AudioService {
usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 }); usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
} }
this.mediaElement = this.createMediaElement();
const ws = WaveSurfer.create({ const ws = WaveSurfer.create({
container, container,
// Provide a persistent audio element so playback continues even when // Fresh audio element per song. Lives on document.body so playback
// the SongPage container div is removed from the DOM on navigation. // continues even when the SongPage container is removed from the DOM.
media: this.getOrCreateMediaElement(), media: this.mediaElement,
waveColor: "rgba(255,255,255,0.09)", waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a", progressColor: "#c8861a",
cursorColor: "#e8a22a", cursorColor: "#e8a22a",
@@ -146,13 +149,17 @@ class AudioService {
private destroyWaveSurfer(): void { private destroyWaveSurfer(): void {
if (!this.wavesurfer) return; if (!this.wavesurfer) return;
try { try {
// Don't pause — if audio is playing and we're switching songs, we want
// the pause to happen naturally when the media element src changes.
this.wavesurfer.unAll(); this.wavesurfer.unAll();
this.wavesurfer.destroy(); this.wavesurfer.destroy();
// Remove the old media element after WaveSurfer finishes its own cleanup.
if (this.mediaElement) {
this.mediaElement.pause();
this.mediaElement.remove();
}
} catch (err) { } catch (err) {
console.error('[AudioService] Error destroying WaveSurfer:', err); console.error('[AudioService] Error destroying WaveSurfer:', err);
} }
this.mediaElement = null;
this.wavesurfer = null; this.wavesurfer = null;
this.currentUrl = null; this.currentUrl = null;
this.isReady = false; this.isReady = false;