import WaveSurfer from "wavesurfer.js"; import { usePlayerStore } from "../stores/playerStore"; class AudioService { private static instance: AudioService; private wavesurfer: WaveSurfer | null = null; private currentUrl: string | null = null; private currentContainer: HTMLElement | null = null; private isReady = false; private lastTimeUpdate = 0; // Persistent audio element attached to document.body so playback survives // SongPage unmounts. WaveSurfer v7 supports passing an existing media element // via the `media` option — it uses it for playback but does NOT destroy it // when WaveSurfer.destroy() is called. private mediaElement: HTMLAudioElement | null = null; private constructor() {} public static getInstance(): AudioService { if (!this.instance) { this.instance = new AudioService(); } return this.instance; } // For use in tests only public static resetInstance(): void { this.instance?.cleanup(); this.instance = undefined as unknown as AudioService; } 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, peaks?: number[] | null): Promise { if (!container) throw new Error('Container element is required'); if (!url) throw new Error('Valid audio URL is required'); // Same URL and same container — nothing to do if (this.currentUrl === url && this.wavesurfer && this.currentContainer === container) return; // Same URL, different container: navigated away and back to the same song. // Move the waveform canvas to the new container without reloading audio. if (this.currentUrl === url && this.wavesurfer) { this.wavesurfer.setOptions({ container }); this.currentContainer = container; return; } // Different URL — tear down the previous instance and clear stale store state if (this.wavesurfer) { this.destroyWaveSurfer(); usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 }); } this.mediaElement = this.createMediaElement(); const ws = WaveSurfer.create({ container, // 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(20,184,166,0.18)", progressColor: "#14b8a6", cursorColor: "#2dd4bf", barWidth: 2, barRadius: 2, height: 104, normalize: true, autoplay: false, }); this.wavesurfer = ws; this.currentUrl = url; this.currentContainer = container; this.setupEventHandlers(ws); await new Promise((resolve, reject) => { const onReady = async () => { const duration = ws.getDuration(); if (duration > 0) { usePlayerStore.getState().setDuration(duration); this.isReady = true; resolve(); } else { reject(new Error('Audio loaded but duration is 0')); } }; ws.on('ready', () => { onReady().catch(reject); }); ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err)))); // Pass pre-computed peaks to WaveSurfer so the waveform renders immediately // without waiting for the full audio to decode (WaveSurfer v7 feature). if (peaks && peaks.length > 0) { ws.load(url, [new Float32Array(peaks)]); } else { ws.load(url); } }); } private setupEventHandlers(ws: WaveSurfer): void { ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true })); ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false })); ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false })); ws.on('audioprocess', (time) => { const now = Date.now(); if (now - this.lastTimeUpdate >= 250) { usePlayerStore.getState().batchUpdate({ currentTime: time }); this.lastTimeUpdate = now; } }); } public async play(songId: string | null = null, bandId: string | null = null): Promise { if (!this.wavesurfer || !this.isReady) return; await this.wavesurfer.play(); if (songId && bandId) { usePlayerStore.getState().setCurrentSong(songId, bandId); } } public pause(): void { this.wavesurfer?.pause(); } public seekTo(time: number): void { if (this.wavesurfer && this.isReady && isFinite(time)) { this.wavesurfer.setTime(time); } } public getCurrentTime(): number { return this.wavesurfer?.getCurrentTime() ?? 0; } public getDuration(): number { return this.wavesurfer?.getDuration() ?? 0; } public isPlaying(): boolean { return this.wavesurfer?.isPlaying() ?? false; } public isWaveformReady(): boolean { return this.isReady && !!this.wavesurfer; } public cleanup(): void { this.destroyWaveSurfer(); const store = usePlayerStore.getState(); store.setCurrentSong(null, null); store.batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 }); } private destroyWaveSurfer(): void { if (!this.wavesurfer) return; try { 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.currentContainer = null; this.isReady = false; } } export const audioService = AudioService.getInstance(); export { AudioService };