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:
@@ -24,19 +24,20 @@ class AudioService {
|
||||
|
||||
// For use in tests only
|
||||
public static resetInstance(): void {
|
||||
if (this.instance?.mediaElement) {
|
||||
this.instance.mediaElement.remove();
|
||||
}
|
||||
this.instance?.cleanup();
|
||||
this.instance = undefined as any;
|
||||
}
|
||||
|
||||
private getOrCreateMediaElement(): HTMLAudioElement {
|
||||
if (!this.mediaElement) {
|
||||
this.mediaElement = document.createElement('audio');
|
||||
this.mediaElement.style.display = 'none';
|
||||
document.body.appendChild(this.mediaElement);
|
||||
}
|
||||
return this.mediaElement;
|
||||
private createMediaElement(): HTMLAudioElement {
|
||||
// Always create a fresh element — never reuse the one from a destroyed
|
||||
// WaveSurfer instance. WaveSurfer.destroy() aborts its internal fetch
|
||||
// signal, which can poison the same element when the next instance tries
|
||||
// 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.
|
||||
const el = document.createElement('audio');
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
||||
@@ -52,11 +53,13 @@ class AudioService {
|
||||
usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
|
||||
}
|
||||
|
||||
this.mediaElement = this.createMediaElement();
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
// Provide a persistent audio element so playback continues even when
|
||||
// the SongPage container div is removed from the DOM on navigation.
|
||||
media: this.getOrCreateMediaElement(),
|
||||
// Fresh audio element per song. Lives on document.body so playback
|
||||
// continues even when the SongPage container is removed from the DOM.
|
||||
media: this.mediaElement,
|
||||
waveColor: "rgba(255,255,255,0.09)",
|
||||
progressColor: "#c8861a",
|
||||
cursorColor: "#e8a22a",
|
||||
@@ -146,13 +149,17 @@ class AudioService {
|
||||
private destroyWaveSurfer(): void {
|
||||
if (!this.wavesurfer) return;
|
||||
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.destroy();
|
||||
// Remove the old media element after WaveSurfer finishes its own cleanup.
|
||||
if (this.mediaElement) {
|
||||
this.mediaElement.pause();
|
||||
this.mediaElement.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
||||
}
|
||||
this.mediaElement = null;
|
||||
this.wavesurfer = null;
|
||||
this.currentUrl = null;
|
||||
this.isReady = false;
|
||||
|
||||
Reference in New Issue
Block a user