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:
Mistral Vibe
2026-04-08 20:44:07 +02:00
parent ef73e45da2
commit 1a0d926e1a
2 changed files with 182 additions and 152 deletions

View File

@@ -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
});
}
}
};