audioService.ts rewritten from ~850 lines to ~130: - Remove custom logging system with throttle that suppressed ERROR logs - Remove AudioContext management entirely (initializeAudioContext, handleAudioContextResume, setupAudioContext, shareAudioContextWithWaveSurfer, ensureAudioContext). WaveSurfer v7 owns its AudioContext; fighting it caused prod/dev divergence and silent failures. - Replace 5-state InitializationState machine + split promise with a single isReady boolean set in the 'ready' event handler - Remove retry/debounce logic from play() — these are UI concerns - Remove dead methods: canPlayAudio (always returned true), getWaveSurferVersion, updatePlayerState, getAudioContextState, setLogLevel - Extract destroyWaveSurfer() helper so cleanup is one place - MiniPlayer now passes songId/bandId to play() (was calling with no args) - SongPage spacebar handler simplified: just checks isReady from hook - SongPage no longer imports audioService directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
4.3 KiB
TypeScript
Executable File
152 lines
4.3 KiB
TypeScript
Executable File
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 isReady = false;
|
|
private lastTimeUpdate = 0;
|
|
|
|
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 = undefined as any;
|
|
}
|
|
|
|
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
|
if (!container) throw new Error('Container element is required');
|
|
if (!url) throw new Error('Valid audio URL is required');
|
|
|
|
// Reuse the existing instance when the URL hasn't changed
|
|
if (this.currentUrl === url && this.wavesurfer) return;
|
|
|
|
// Tear down the previous instance before creating a new one
|
|
if (this.wavesurfer) this.destroyWaveSurfer();
|
|
|
|
const ws = WaveSurfer.create({
|
|
container,
|
|
waveColor: "rgba(255,255,255,0.09)",
|
|
progressColor: "#c8861a",
|
|
cursorColor: "#e8a22a",
|
|
barWidth: 2,
|
|
barRadius: 2,
|
|
height: 104,
|
|
normalize: true,
|
|
autoplay: false,
|
|
});
|
|
|
|
this.wavesurfer = ws;
|
|
this.currentUrl = url;
|
|
this.setupEventHandlers(ws);
|
|
|
|
await new Promise<void>((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))));
|
|
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<void> {
|
|
if (!this.wavesurfer || !this.isReady) {
|
|
console.warn('[AudioService] play() called before ready');
|
|
return;
|
|
}
|
|
await this.wavesurfer.play();
|
|
if (songId && bandId) {
|
|
usePlayerStore.getState().setCurrentPlayingSong(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;
|
|
}
|
|
|
|
// Aliases kept for callers until Phase 3 cleans them up
|
|
public isReadyForPlayback(): boolean {
|
|
return this.isWaveformReady();
|
|
}
|
|
|
|
public canAttemptPlayback(): boolean {
|
|
return this.isWaveformReady();
|
|
}
|
|
|
|
public cleanup(): void {
|
|
this.destroyWaveSurfer();
|
|
const store = usePlayerStore.getState();
|
|
store.setCurrentPlayingSong(null, null);
|
|
store.batchUpdate({ isPlaying: false, currentTime: 0 });
|
|
}
|
|
|
|
private destroyWaveSurfer(): void {
|
|
if (!this.wavesurfer) return;
|
|
try {
|
|
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
|
|
this.wavesurfer.unAll();
|
|
this.wavesurfer.destroy();
|
|
} catch (err) {
|
|
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
|
}
|
|
this.wavesurfer = null;
|
|
this.currentUrl = null;
|
|
this.isReady = false;
|
|
}
|
|
}
|
|
|
|
export const audioService = AudioService.getInstance();
|
|
export { AudioService };
|