Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 113 additions & 99 deletions src/hooks/useAudioPreloader.ts
Original file line number Diff line number Diff line change
@@ -1,175 +1,189 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useState, useRef, useCallback } from "react";

interface AudioSnippet {
lineNumber: number
audioUrl: string
lineNumber: number;
audioUrl: string;
}

interface PreloadState {
loaded: number
total: number
ready: boolean
loaded: number;
total: number;
ready: boolean;
}

interface UseAudioPreloaderReturn extends PreloadState {
play: (lineNumber: number) => void
stop: () => void
pause: () => void
resume: () => void
isPlaying: boolean
setPlaybackRate: (rate: number) => void
setLoop: (loop: boolean) => void
play: (lineNumber: number) => void;
stop: () => void;
pause: () => void;
resume: () => void;
isPlaying: boolean;
setPlaybackRate: (rate: number) => void;
setLoop: (loop: boolean) => void;
}

export function useAudioPreloader(
snippets: AudioSnippet[],
onEnded?: (lineNumber: number) => void
onEnded?: (lineNumber: number) => void,
): UseAudioPreloaderReturn {
Comment on lines 24 to 27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find test files for useAudioPreloader
fd -i 'useaudiopreloader' --type f

Repository: EtanHey/songscript

Length of output: 92


🏁 Script executed:

# Find any test files in the hooks directory
fd '(test|spec)\.(ts|tsx)$' src/hooks/ || fd 'hooks' src/ --type d

Repository: EtanHey/songscript

Length of output: 130


🏁 Script executed:

# Look at the hook implementation
cat -n src/hooks/useAudioPreloader.ts | head -50

Repository: EtanHey/songscript

Length of output: 1822


🏁 Script executed:

# Search for onEnded references in test files
rg -l 'onEnded' --type ts --type tsx src/

Repository: EtanHey/songscript

Length of output: 89


🏁 Script executed:

# Search more comprehensively for any test files related to useAudioPreloader
find . -type f -name "*useAudioPreloader*"

Repository: EtanHey/songscript

Length of output: 94


🏁 Script executed:

# Check if there are test files in a different location
find src/ -type f \( -name "*.test.ts" -o -name "*.spec.ts" -o -name "*.test.tsx" -o -name "*.spec.tsx" \)

Repository: EtanHey/songscript

Length of output: 435


🏁 Script executed:

# Search for onEnded in the full hook implementation to understand the fix better
cat -n src/hooks/useAudioPreloader.ts

Repository: EtanHey/songscript

Length of output: 6642


🏁 Script executed:

# Check if there's a test directory at root level
find . -maxdepth 2 -type d -name "*test*" -o -name "*__tests__*"

Repository: EtanHey/songscript

Length of output: 88


Add test file for useAudioPreloader hook with regression coverage for the onEnded callback behavior.

The hook is missing a test file entirely, violating the coding guideline requiring test files for all utilities. Additionally, add a focused regression test confirming that the onEnded callback (lines 39–42) correctly uses the latest callback instance when it changes between renders, without triggering effect re-runs that would reload audio elements. Ensure bun run test passes locally before committing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` around lines 24 - 27, Add a new test file for
the useAudioPreloader hook (e.g.,
src/hooks/__tests__/useAudioPreloader.test.tsx) that verifies regression
coverage for the onEnded callback behavior: mount a component that calls
useAudioPreloader with a small list of AudioSnippet items, spy/mock
HTMLAudioElement events or provide a fake audio element to avoid network, assert
that when you update the onEnded prop to a new function it is invoked on audio
end using the latest function instance (referencing onEnded and
useAudioPreloader) and that the hook does not re-run its effect or recreate
audio elements when the callback changes (i.e., audio elements remain the same
across rerenders). Use React Testing Library or similar to rerender, advance
timers or dispatch the 'ended' event to trigger callbacks, and assert no
additional audio element loads occurred; run and confirm bun run test passes
locally before committing.

const [state, setState] = useState<PreloadState>({
loaded: 0,
total: snippets.length,
ready: false,
})
const [isPlaying, setIsPlaying] = useState(false)
const audioMapRef = useRef<Map<number, HTMLAudioElement>>(new Map())
const currentlyPlayingRef = useRef<HTMLAudioElement | null>(null)
const playbackRateRef = useRef<number>(1)
const loopRef = useRef<boolean>(false)
});
const [isPlaying, setIsPlaying] = useState(false);
const audioMapRef = useRef<Map<number, HTMLAudioElement>>(new Map());
const currentlyPlayingRef = useRef<HTMLAudioElement | null>(null);
const playbackRateRef = useRef<number>(1);
const loopRef = useRef<boolean>(false);

// Keep onEnded in a ref so the audio 'ended' handler always calls the latest callback
// without needing to re-run the effect (which would reload all audio elements)
const onEndedRef = useRef(onEnded);
onEndedRef.current = onEnded;

useEffect(() => {
if (snippets.length === 0) {
setState({ loaded: 0, total: 0, ready: true })
return
setState({ loaded: 0, total: 0, ready: true });
return;
}

let loadedCount = 0
const total = snippets.length
let loadedCount = 0;
const total = snippets.length;

// Reset state for new snippets
setState({ loaded: 0, total, ready: false })
audioMapRef.current.clear()
setState({ loaded: 0, total, ready: false });
audioMapRef.current.clear();

snippets.forEach(({ lineNumber, audioUrl }) => {
if (!audioUrl) return
if (!audioUrl) return;

Comment on lines +50 to 59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ready can stay false indefinitely when some snippets have empty audioUrl.

total is based on all snippets, but Line 58 skips empty URLs without incrementing loadedCount. In that case, loaded !== total forever.

Proposed fix
-    let loadedCount = 0;
-    const total = snippets.length;
+    let loadedCount = 0;
+    const loadableSnippets = snippets.filter(
+      ({ audioUrl }) => Boolean(audioUrl),
+    );
+    const total = loadableSnippets.length;

     // Reset state for new snippets
     setState({ loaded: 0, total, ready: false });
     audioMapRef.current.clear();

-    snippets.forEach(({ lineNumber, audioUrl }) => {
-      if (!audioUrl) return;
+    if (total === 0) {
+      setState({ loaded: 0, total: 0, ready: true });
+      return;
+    }
+
+    loadableSnippets.forEach(({ lineNumber, audioUrl }) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let loadedCount = 0;
const total = snippets.length;
// Reset state for new snippets
setState({ loaded: 0, total, ready: false })
audioMapRef.current.clear()
setState({ loaded: 0, total, ready: false });
audioMapRef.current.clear();
snippets.forEach(({ lineNumber, audioUrl }) => {
if (!audioUrl) return
if (!audioUrl) return;
let loadedCount = 0;
const loadableSnippets = snippets.filter(
({ audioUrl }) => Boolean(audioUrl),
);
const total = loadableSnippets.length;
// Reset state for new snippets
setState({ loaded: 0, total, ready: false });
audioMapRef.current.clear();
if (total === 0) {
setState({ loaded: 0, total: 0, ready: true });
return;
}
loadableSnippets.forEach(({ lineNumber, audioUrl }) => {
if (!audioUrl) return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` around lines 50 - 59, The bug is that total
is set to snippets.length but snippets.forEach skips entries with empty
audioUrl, so loadedCount never reaches total and ready stays false; fix by
either (a) compute total as the number of snippets with a non-empty audioUrl
before starting (e.g., derive total = snippets.filter(s => s.audioUrl).length)
or (b) when skipping inside snippets.forEach (the if (!audioUrl) return),
increment loadedCount and update setState accordingly; ensure you update
setState({ loaded, total, ready: loaded === total }) and reference loadedCount,
total, setState, snippets.forEach, audioUrl and audioMapRef.current.clear to
apply the change.

const audio = new Audio()
audio.preload = 'auto'
let handled = false // Prevent double counting from canplay + canplaythrough
const audio = new Audio();
audio.preload = "auto";
let handled = false; // Prevent double counting from canplay + canplaythrough

const handleCanPlay = () => {
if (handled) return
handled = true
loadedCount++
audioMapRef.current.set(lineNumber, audio)
if (handled) return;
handled = true;
loadedCount++;
audioMapRef.current.set(lineNumber, audio);
setState({
loaded: loadedCount,
total,
ready: loadedCount === total,
})
}
});
};

const handleError = () => {
if (handled) return
handled = true
if (handled) return;
handled = true;
// Still increment loaded count to not block ready state
loadedCount++
console.warn(`Failed to load audio for line ${lineNumber}: ${audioUrl}`)
loadedCount++;
console.warn(
`Failed to load audio for line ${lineNumber}: ${audioUrl}`,
);
setState({
loaded: loadedCount,
total,
ready: loadedCount === total,
})
}
});
};

const handleEnded = () => {
setIsPlaying(false)
if (onEnded) {
onEnded(lineNumber)
}
}

audio.addEventListener('canplaythrough', handleCanPlay, { once: true })
audio.addEventListener('canplay', handleCanPlay, { once: true }) // Fallback for cached audio
audio.addEventListener('error', handleError, { once: true })
audio.addEventListener('ended', handleEnded)
audio.src = audioUrl
audio.load() // Explicitly trigger loading
})
setIsPlaying(false);
onEndedRef.current?.(lineNumber);
};

audio.addEventListener("canplaythrough", handleCanPlay, { once: true });
audio.addEventListener("canplay", handleCanPlay, { once: true }); // Fallback for cached audio
audio.addEventListener("error", handleError, { once: true });
audio.addEventListener("ended", handleEnded);
audio.src = audioUrl;
audio.load(); // Explicitly trigger loading
});

return () => {
// Cleanup all audio elements on unmount
audioMapRef.current.forEach(audio => {
audio.pause()
audio.removeEventListener('ended', () => {})
audio.src = ''
})
audioMapRef.current.clear()
currentlyPlayingRef.current = null
}
}, [snippets])
audioMapRef.current.forEach((audio) => {
audio.pause();
audio.removeEventListener("ended", () => {});
audio.src = "";
});
audioMapRef.current.clear();
currentlyPlayingRef.current = null;
};
}, [snippets]);

const play = useCallback((lineNumber: number) => {
// Stop any currently playing audio
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.pause()
currentlyPlayingRef.current.currentTime = 0
currentlyPlayingRef.current.pause();
currentlyPlayingRef.current.currentTime = 0;
}

const audio = audioMapRef.current.get(lineNumber)
const audio = audioMapRef.current.get(lineNumber);
if (audio) {
audio.currentTime = 0
audio.playbackRate = playbackRateRef.current
audio.loop = loopRef.current
audio.currentTime = 0;
audio.playbackRate = playbackRateRef.current;
audio.loop = loopRef.current;
audio.play().catch((err) => {
console.warn(`Failed to play audio for line ${lineNumber}:`, err)
setIsPlaying(false)
})
currentlyPlayingRef.current = audio
setIsPlaying(true)
console.warn(`Failed to play audio for line ${lineNumber}:`, err);
setIsPlaying(false);
});
currentlyPlayingRef.current = audio;
setIsPlaying(true);
}
}, [])
}, []);
Comment on lines 116 to +135
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

play() can leave stale playback state when target audio is unavailable.

After stopping current audio, if audioMapRef has no entry for the requested line, currentlyPlayingRef and isPlaying are not reset consistently.

Proposed fix
   const play = useCallback((lineNumber: number) => {
     // Stop any currently playing audio
     if (currentlyPlayingRef.current) {
       currentlyPlayingRef.current.pause();
       currentlyPlayingRef.current.currentTime = 0;
     }

     const audio = audioMapRef.current.get(lineNumber);
-    if (audio) {
-      audio.currentTime = 0;
-      audio.playbackRate = playbackRateRef.current;
-      audio.loop = loopRef.current;
-      audio.play().catch((err) => {
-        console.warn(`Failed to play audio for line ${lineNumber}:`, err);
-        setIsPlaying(false);
-      });
-      currentlyPlayingRef.current = audio;
-      setIsPlaying(true);
-    }
+    if (!audio) {
+      currentlyPlayingRef.current = null;
+      setIsPlaying(false);
+      return;
+    }
+
+    audio.currentTime = 0;
+    audio.playbackRate = playbackRateRef.current;
+    audio.loop = loopRef.current;
+    currentlyPlayingRef.current = audio;
+    setIsPlaying(true);
+    audio.play().catch((err) => {
+      console.warn(`Failed to play audio for line ${lineNumber}:`, err);
+      currentlyPlayingRef.current = null;
+      setIsPlaying(false);
+    });
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const play = useCallback((lineNumber: number) => {
// Stop any currently playing audio
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.pause()
currentlyPlayingRef.current.currentTime = 0
currentlyPlayingRef.current.pause();
currentlyPlayingRef.current.currentTime = 0;
}
const audio = audioMapRef.current.get(lineNumber)
const audio = audioMapRef.current.get(lineNumber);
if (audio) {
audio.currentTime = 0
audio.playbackRate = playbackRateRef.current
audio.loop = loopRef.current
audio.currentTime = 0;
audio.playbackRate = playbackRateRef.current;
audio.loop = loopRef.current;
audio.play().catch((err) => {
console.warn(`Failed to play audio for line ${lineNumber}:`, err)
setIsPlaying(false)
})
currentlyPlayingRef.current = audio
setIsPlaying(true)
console.warn(`Failed to play audio for line ${lineNumber}:`, err);
setIsPlaying(false);
});
currentlyPlayingRef.current = audio;
setIsPlaying(true);
}
}, [])
}, []);
const play = useCallback((lineNumber: number) => {
// Stop any currently playing audio
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.pause();
currentlyPlayingRef.current.currentTime = 0;
}
const audio = audioMapRef.current.get(lineNumber);
if (!audio) {
currentlyPlayingRef.current = null;
setIsPlaying(false);
return;
}
audio.currentTime = 0;
audio.playbackRate = playbackRateRef.current;
audio.loop = loopRef.current;
currentlyPlayingRef.current = audio;
setIsPlaying(true);
audio.play().catch((err) => {
console.warn(`Failed to play audio for line ${lineNumber}:`, err);
currentlyPlayingRef.current = null;
setIsPlaying(false);
});
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` around lines 116 - 135, The play function can
leave stale state when the requested audio is missing; after stopping any
currentlyPlayingRef, ensure you clear currentlyPlayingRef.current and call
setIsPlaying(false) if audioMapRef.current.get(lineNumber) is undefined or null
before returning; modify the play callback (function play) to check for the
audio entry and explicitly reset currentlyPlayingRef and isPlaying when no audio
is found, while keeping the existing behavior when audio exists (setting
playbackRate, loop, handling play promise and updating
currentlyPlayingRef/setIsPlaying).


const stop = useCallback(() => {
audioMapRef.current.forEach(audio => {
audio.pause()
audio.currentTime = 0
})
currentlyPlayingRef.current = null
setIsPlaying(false)
}, [])
audioMapRef.current.forEach((audio) => {
audio.pause();
audio.currentTime = 0;
});
currentlyPlayingRef.current = null;
setIsPlaying(false);
}, []);

const pause = useCallback(() => {
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.pause()
setIsPlaying(false)
currentlyPlayingRef.current.pause();
setIsPlaying(false);
}
}, [])
}, []);

const resume = useCallback(() => {
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.play().catch((err) => {
console.warn('Failed to resume audio:', err)
setIsPlaying(false)
})
setIsPlaying(true)
console.warn("Failed to resume audio:", err);
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [])
}, []);

const setPlaybackRate = useCallback((rate: number) => {
playbackRateRef.current = rate
playbackRateRef.current = rate;
// Update rate on currently playing audio immediately
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.playbackRate = rate
currentlyPlayingRef.current.playbackRate = rate;
}
}, [])
}, []);

const setLoop = useCallback((loop: boolean) => {
loopRef.current = loop
loopRef.current = loop;
// Update loop on currently playing audio immediately
if (currentlyPlayingRef.current) {
currentlyPlayingRef.current.loop = loop
currentlyPlayingRef.current.loop = loop;
}
}, [])

return { ...state, play, stop, pause, resume, isPlaying, setPlaybackRate, setLoop }
}, []);

return {
...state,
play,
stop,
pause,
resume,
isPlaying,
setPlaybackRate,
setLoop,
};
}