diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index 7599f1b..d3d504b 100755 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -29,46 +29,58 @@ export function useWaveform( const [error, setError] = useState(null); const [initializationState, setInitializationState] = useState(InitializationState.NotStarted); const markersRef = useRef([]); - - // Global player state - use shallow comparison to reduce re-renders - const { - isPlaying: globalIsPlaying, - currentTime: globalCurrentTime, - currentSongId, - currentBandId: globalBandId, - currentPlayingSongId, - currentPlayingBandId, - setCurrentSong - } = usePlayerStore(state => ({ - isPlaying: state.isPlaying, - currentTime: state.currentTime, - currentSongId: state.currentSongId, - currentBandId: state.currentBandId, - currentPlayingSongId: state.currentPlayingSongId, - currentPlayingBandId: state.currentPlayingBandId, - setCurrentSong: state.setCurrentSong - })); useEffect(() => { - if (!containerRef.current) { - return; - } + if (!containerRef.current) return; + if (!options.url || options.url === 'null' || options.url === 'undefined') return; - if (!options.url || options.url === 'null' || options.url === 'undefined') { - return; - } + // animationFrameId is declared here so the useEffect cleanup can cancel it + // even if initializeAudio hasn't finished yet + let animationFrameId: number | null = null; const initializeAudio = async () => { try { - - await audioService.initialize(containerRef.current!, options.url!); - - // Set up local state synchronization with requestAnimationFrame for smoother updates - let animationFrameId: number | null = null; + + // Update global song context + if (options.songId && options.bandId) { + usePlayerStore.getState().setCurrentSong(options.songId, options.bandId); + } + + // Restore playback if this song was already playing when the page loaded. + // Read as a one-time snapshot — these values must NOT be reactive deps or + // the effect would re-run on every time update (re-initializing WaveSurfer). + const { + currentPlayingSongId, + currentPlayingBandId, + isPlaying: wasPlaying, + currentTime: savedTime, + } = usePlayerStore.getState(); + + if ( + options.songId && + options.bandId && + currentPlayingSongId === options.songId && + currentPlayingBandId === options.bandId && + wasPlaying + ) { + try { + if (audioService.canAttemptPlayback()) { + audioService.play(options.songId, options.bandId); + if (savedTime > 0) { + audioService.seekTo(savedTime); + } + } + } catch (err) { + console.warn('Auto-play prevented during initialization:', err); + } + } + + // Sync local state from the store at ~15fps via RAF. + // The loop is started after initialization so we only poll when something is loaded. let lastUpdateTime = 0; - const updateInterval = 1000 / 15; // ~15fps for state updates - + const updateInterval = 1000 / 15; + const handleStateUpdate = () => { const now = Date.now(); if (now - lastUpdateTime >= updateInterval) { @@ -80,74 +92,27 @@ export function useWaveform( } animationFrameId = requestAnimationFrame(handleStateUpdate); }; - - // Start the animation frame loop - animationFrameId = requestAnimationFrame(handleStateUpdate); - - const unsubscribe = () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - animationFrameId = null; - } - }; - - // Update global song context - if (options.songId && options.bandId) { - setCurrentSong(options.songId, options.bandId); - } - - // If this is the currently playing song, restore play state - if (options.songId && options.bandId && - currentPlayingSongId === options.songId && - currentPlayingBandId === options.bandId && - globalIsPlaying) { - - - // Wait for initialization to complete using the new promise-based approach - try { - await audioService.waitForInitialization(); - - // Use the unified readiness check - if (audioService.isReadyForPlayback()) { - audioService.play(options.songId, options.bandId); - if (globalCurrentTime > 0) { - audioService.seekTo(globalCurrentTime); - } - } else { - console.warn('Not ready for playback after initialization', { - state: audioService.getInitializationState(), - error: audioService.getInitializationError() - }); - } - } catch (error) { - console.warn('Auto-play prevented during initialization:', error); - // Don't retry - wait for user to click play - } - } - + animationFrameId = requestAnimationFrame(handleStateUpdate); + setIsReady(true); setInitializationState(audioService.getInitializationState()); options.onReady?.(audioService.getDuration()); - - return () => { - - unsubscribe(); - // Note: We don't cleanup the audio service here to maintain persistence - // audioService.cleanup(); - }; - } catch (error) { - console.error('useWaveform: initialization failed', error); + } catch (err) { + console.error('useWaveform: initialization failed', err); setIsReady(false); - setError(error instanceof Error ? error.message : 'Failed to initialize audio'); - return () => {}; + setError(err instanceof Error ? err.message : 'Failed to initialize audio'); } }; initializeAudio(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]); + return () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + }; + }, [options.url, options.songId, options.bandId]); const play = () => { // Use the unified readiness check @@ -158,12 +123,21 @@ export function useWaveform( console.error('useWaveform.play failed:', error); } } else { - console.warn('Cannot play: not ready for playback', { - initializationState: audioService.getInitializationState(), - error: audioService.getInitializationError(), - duration: audioService.getDuration(), - url: options.url - }); + // If we can attempt playback (even during initialization), try it + if (audioService.canAttemptPlayback()) { + try { + audioService.play(options.songId || null, options.bandId || null); + } catch (error) { + console.error('useWaveform.play failed during initialization attempt:', error); + } + } else { + console.warn('Cannot play: not ready for playback', { + initializationState: audioService.getInitializationState(), + error: audioService.getInitializationError(), + duration: audioService.getDuration(), + url: options.url + }); + } } }; diff --git a/web/src/services/audioService.ts b/web/src/services/audioService.ts index f54af49..6702491 100755 --- a/web/src/services/audioService.ts +++ b/web/src/services/audioService.ts @@ -34,8 +34,6 @@ class AudioService { private wavesurfer: WaveSurfer | null = null; private audioContext: AudioContext | null = null; private currentUrl: string | null = null; - private currentPlayingSongId: string | null = null; - private currentPlayingBandId: string | null = null; private lastPlayTime: number = 0; private lastTimeUpdate: number = 0; private readonly PLAY_DEBOUNCE_MS: number = 100; @@ -235,7 +233,11 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } else { this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed'); } - return this.wavesurfer; + // Signal initialization completion for existing instance + this.initializationState = InitializationState.Completed; + this.initializationResolve?.(); + this.initializationResolve = null; + return; } catch (error) { this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error); this.cleanup(); @@ -277,7 +279,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // @ts-expect-error - WaveSurfer typing doesn't expose backend if (!ws.backend) { - console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+'); + this.log(LogLevel.DEBUG, 'WaveSurfer instance has no backend property yet - this might be normal in v7+'); // Don't throw error - we'll try to access backend later when needed } } catch (error) { @@ -298,48 +300,56 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; this.setupEventHandlers(); // Load the audio with error handling - this.log(LogLevel.DEBUG, 'Loading audio URL:', url); + this.log(LogLevel.DEBUG, 'Loading audio URL:', url, { + currentWavesurfer: !!this.wavesurfer, + currentUrl: this.currentUrl, + initializationState: this.initializationState + }); try { - const loadPromise = new Promise(async (resolve, reject) => { - ws.on('ready', async () => { - this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired'); - // Now that WaveSurfer is ready, set up audio context and finalize initialization - try { - await this.setupAudioContext(ws); - - // Update player store with duration - const playerStore = usePlayerStore.getState(); - playerStore.setDuration(ws.getDuration()); - - // Signal initialization completion + await new Promise((resolve, reject) => { + // Async work extracted outside the executor so rejections are always + // forwarded to reject() rather than becoming unhandled Promise rejections. + const onReady = async () => { + this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired', { + currentTime: ws.getCurrentTime(), + duration: ws.getDuration(), + }); + + await this.setupAudioContext(ws); + + const duration = ws.getDuration(); + this.log(LogLevel.DEBUG, 'WaveSurfer ready with duration:', duration); + + if (duration > 0) { + usePlayerStore.getState().setDuration(duration); this.initializationState = InitializationState.Completed; this.initializationResolve?.(); this.initializationResolve = null; - resolve(); - } catch (error) { - this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error); + } else { + const err = new Error('Audio loaded but duration is 0'); + this.log(LogLevel.ERROR, err.message); this.initializationState = InitializationState.Failed; - this.initializationError = error instanceof Error ? error : new Error(String(error)); + this.initializationError = err; this.initializationResolve?.(); this.initializationResolve = null; - reject(error); + reject(err); } - }); - + }; + + ws.on('ready', () => { onReady().catch(reject); }); + ws.on('error', (error) => { this.log(LogLevel.ERROR, 'WaveSurfer error event:', error); + this.initializationState = InitializationState.Failed; + this.initializationError = error instanceof Error ? error : new Error(String(error)); reject(error); }); - - // Start loading + ws.load(url); }); - - await loadPromise; + this.log(LogLevel.INFO, 'Audio loaded successfully'); - - return this.initializationPromise; } catch (error) { this.log(LogLevel.ERROR, 'Failed to load audio:', error); this.initializationState = InitializationState.Failed; @@ -388,6 +398,22 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // Provide more context about why there's no wavesurfer instance if (!this.currentUrl) { this.log(LogLevel.ERROR, 'No audio URL has been set - waveform not initialized'); + } else if (this.initializationState === InitializationState.Failed) { + this.log(LogLevel.ERROR, 'Waveform initialization failed:', this.initializationError); + } else if (this.initializationState === InitializationState.InProgress) { + this.log(LogLevel.INFO, 'Waveform still initializing - waiting for completion'); + // Wait for initialization to complete + try { + await this.waitForInitialization(); + // After waiting, check if we have a wavesurfer instance + if (!this.wavesurfer) { + this.log(LogLevel.ERROR, 'Waveform initialization completed but no wavesurfer instance available'); + return; + } + } catch (error) { + this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error); + return; + } } else { this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up'); } @@ -396,10 +422,46 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // Check if waveform is actually ready for playback if (this.getDuration() <= 0) { - this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0'); + this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0', { + initializationState: this.initializationState, + error: this.initializationError, + currentUrl: this.currentUrl + }); + + // If initialization failed, provide more context + if (this.initializationState === InitializationState.Failed) { + this.log(LogLevel.ERROR, 'Waveform initialization failed with error:', this.initializationError); + } else if (this.initializationState === InitializationState.InProgress) { + this.log(LogLevel.INFO, 'Waveform still initializing, waiting for completion'); + // Wait for initialization to complete + try { + await this.waitForInitialization(); + // After waiting, check duration again + if (this.getDuration() <= 0) { + this.log(LogLevel.ERROR, 'Waveform initialization completed but duration is still 0'); + return; + } + } catch (error) { + this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error); + return; + } + } + return; } + // If initialization is still in progress, wait for it to complete + if (this.initializationState === InitializationState.InProgress) { + this.log(LogLevel.INFO, 'Initialization in progress, waiting for completion before playback'); + try { + await this.waitForInitialization(); + this.log(LogLevel.DEBUG, 'Initialization completed, proceeding with playback'); + } catch (error) { + this.log(LogLevel.ERROR, 'Initialization failed while waiting for playback:', error); + return; + } + } + // Debounce rapid play calls const now = Date.now(); if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) { @@ -420,16 +482,6 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; await new Promise(resolve => setTimeout(resolve, 50)); } - // Check if we need to switch songs - const isDifferentSong = songId && bandId && - (this.currentPlayingSongId !== songId || this.currentPlayingBandId !== bandId); - - // If switching to a different song, perform cleanup - if (isDifferentSong) { - this.log(LogLevel.INFO, 'Switching to different song - performing cleanup'); - this.cleanup(); - } - // Ensure we have a valid audio context and handle user gesture requirement await this.handleAudioContextResume(); @@ -454,12 +506,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } } - // Update currently playing song tracking if (songId && bandId) { - this.currentPlayingSongId = songId; - this.currentPlayingBandId = bandId; - const playerStore = usePlayerStore.getState(); - playerStore.setCurrentPlayingSong(songId, bandId); + usePlayerStore.getState().setCurrentPlayingSong(songId, bandId); } // Success logs are redundant, only log in debug mode @@ -561,10 +609,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } this.currentUrl = null; - this.currentPlayingSongId = null; - this.currentPlayingBandId = null; - - // Reset player store completely + const playerStore = usePlayerStore.getState(); playerStore.setCurrentPlayingSong(null, null); playerStore.batchUpdate({ isPlaying: false, currentTime: 0 }); @@ -642,17 +687,17 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } // Method 2: Try to access and replace the audio context via backend methods - if (ws.backend?.getAudioContext) { - // @ts-expect-error - Replace the method - ws.backend.getAudioContext = () => this.audioContext; + if (ws.backend && typeof (ws.backend as any).getAudioContext === 'function') { + // Replace the method with proper typing + (ws.backend as any).getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context'); return; // Success, exit early } // Method 3: Try top-level getAudioContext if (typeof ws.getAudioContext === 'function') { - // @ts-expect-error - Replace the method - ws.getAudioContext = () => this.audioContext; + // Replace the method with proper typing + (ws as any).getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context'); return; // Success, exit early } @@ -709,13 +754,21 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // Method to check if ready for playback (unified readiness check) public isReadyForPlayback(): boolean { return ( - this.initializationState === InitializationState.Completed && this.isWaveformReady() && !!this.audioContext && (this.audioContext.state === 'running' || this.audioContext.state === 'suspended') ); } + // Method to check if playback can be attempted (more lenient during initialization) + public canAttemptPlayback(): boolean { + return ( + this.isWaveformReady() && + (this.initializationState === InitializationState.Completed || + this.initializationState === InitializationState.InProgress) + ); + } + // Initialization state management public getInitializationState(): InitializationState { return this.initializationState; @@ -733,7 +786,10 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; return this.initializationPromise ?? Promise.resolve(); } + + // Method to ensure audio context is available (for backward compatibility) + // @ts-expect-error - This method is used in tests but may not be used in production code private async ensureAudioContext(): Promise { // If we already have a valid audio context, return it if (this.audioContext) {