fix(audio): Phase 1 — stop re-init loop, fix null-crash in play(), fix RAF leak
- useWaveform: remove globalCurrentTime/globalIsPlaying from useEffect deps; WaveSurfer was re-initializing every 250ms while audio played. Dep array is now [url, songId, bandId]. Store reads inside the effect use getState() snapshots instead of reactive values. - useWaveform: move animationFrameId outside the async function so the useEffect cleanup can actually cancel the RAF loop. Previously the cleanup was returned from the inner async function and React never called it — loops accumulated on every re-render. - audioService: remove isDifferentSong + cleanup() call from play(). cleanup() set this.wavesurfer = null and then play() immediately called this.wavesurfer.play(), throwing a TypeError on every song switch. - audioService: replace new Promise(async executor) anti-pattern in initialize() with a plain executor + extracted onReady().catch(reject) so errors inside the ready handler are always forwarded to the promise. - audioService: remove currentPlayingSongId/currentPlayingBandId private fields whose only reader was the deleted isDifferentSong block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,46 +29,58 @@ export function useWaveform(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
|
||||
const markersRef = useRef<CommentMarker[]>([]);
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user