refactor(audio): Phase 3 — replace RAF polling loop with store subscription

useWaveform.ts:
- Remove requestAnimationFrame polling loop that was re-running after every
  re-initialization and leaking across renders when cleanup didn't fire
- Remove local useState for isPlaying/currentTime/duration; these now come
  directly from usePlayerStore selectors — WaveSurfer event handlers in
  AudioService already write to the store, so no intermediate sync needed
- The useEffect is now a clean async init only; no cleanup needed (AudioService
  persists intentionally across page navigations)

tests/:
- Delete 3 obsolete test files that tested removed APIs (logging system,
  setupAudioContext, ensureAudioContext, initializeAudioContext)
- Add tests/audioService.test.ts: 25 tests covering initialize(), play(),
  pause(), seekTo(), cleanup(), and all WaveSurfer event→store mappings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-08 20:54:18 +02:00
parent d4c0e9d776
commit 7508d78a86
4 changed files with 264 additions and 544 deletions

View File

@@ -22,21 +22,21 @@ export function useWaveform(
containerRef: React.RefObject<HTMLDivElement>,
options: UseWaveformOptions
) {
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const markersRef = useRef<CommentMarker[]>([]);
// Playback state comes directly from the store — no intermediate local state
// or RAF polling loop needed. The store is updated by WaveSurfer event handlers
// in AudioService, so these values are always in sync.
const isPlaying = usePlayerStore(state => state.isPlaying);
const currentTime = usePlayerStore(state => state.currentTime);
const duration = usePlayerStore(state => state.duration);
useEffect(() => {
if (!containerRef.current) 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!);
@@ -72,25 +72,6 @@ export function useWaveform(
}
}
// Sync local state from the store at ~15fps via RAF.
// The loop is started after initialization so we only poll when loaded.
let lastUpdateTime = 0;
const updateInterval = 1000 / 15;
const handleStateUpdate = () => {
const now = Date.now();
if (now - lastUpdateTime >= updateInterval) {
const state = usePlayerStore.getState();
setIsPlaying(state.isPlaying);
setCurrentTime(state.currentTime);
setDuration(state.duration);
lastUpdateTime = now;
}
animationFrameId = requestAnimationFrame(handleStateUpdate);
};
animationFrameId = requestAnimationFrame(handleStateUpdate);
setIsReady(true);
options.onReady?.(audioService.getDuration());
} catch (err) {
@@ -101,12 +82,6 @@ export function useWaveform(
};
initializeAudio();
return () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
}, [options.url, options.songId, options.bandId]);
const play = () => {