Skip to content
Merged
Show file tree
Hide file tree
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
8 changes: 8 additions & 0 deletions models/AudioPlayer.tla
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ PlaySingleSegment(seg) ==
/\ currentSegment' = seg
/\ loopEnabled' = TRUE

\* アンマウント (ページ遷移時のクリーンアップ)
\* どの状態からでも発生し、再生を強制停止する
Unmount ==
/\ playState' = "stopped"
/\ currentSegment' = 0
/\ UNCHANGED <<loopEnabled, selectedSegments>>

-----------------------------------------------------------------------------
(* 状態遷移 *)
Next ==
Expand All @@ -167,6 +174,7 @@ Next ==
\/ SelectAll
\/ SelectNone
\/ \E seg \in Segments : PlaySingleSegment(seg)
\/ Unmount

-----------------------------------------------------------------------------
(* 時間的性質 *)
Expand Down
25 changes: 24 additions & 1 deletion src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export function AudioPlayer({
const playSegmentRef = useRef<((segmentIndex: number) => void) | null>(null);
const onEndedRef = useRef<(() => void) | null>(null);
const onErrorRef = useRef<(() => void) | null>(null);
const resumeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMountedRef = useRef(true);

// Reset when content changes
// biome-ignore lint/correctness/useExhaustiveDependencies: contentId change should reset state
Expand Down Expand Up @@ -122,6 +124,19 @@ export function AudioPlayer({
onErrorRef.current = null;
}, []);

// Stop audio on unmount (e.g. page navigation)
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
cleanupAudio();
if (resumeTimeoutRef.current !== null) {
clearTimeout(resumeTimeoutRef.current);
resumeTimeoutRef.current = null;
}
};
}, [cleanupAudio]);
Comment on lines +127 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Async resume restarts audio 🐞 Bug ⛯ Reliability

The new unmount cleanup only calls cleanupAudio(), but AudioPlayer schedules a setTimeout resume on
language change that is not canceled on unmount. If navigation happens before the timeout fires, the
callback can run after unmount, call playSegment(), and create/play a new Audio instance—so audio
may still continue after navigation.
Agent Prompt
### Issue description
AudioPlayer now calls `cleanupAudio()` on unmount, but there are asynchronous callbacks that can outlive the component (notably a `setTimeout` used to resume after language change). If navigation happens before that timer fires, the callback can still run after unmount and call `playSegment()` (creating a new `Audio()`), so audio may continue after navigation.

### Issue Context
- Unmount cleanup currently only pauses/removes listeners on the current `audioRef`.
- `handleLanguageChange()` schedules a timer that resumes playback.
- `playSegment()` also has async promise callbacks (`audio.play().catch`) that can call `setState` after unmount.

### Fix Focus Areas
- src/components/AudioPlayer.tsx[109-130]
- src/components/AudioPlayer.tsx[224-240]
- src/components/AudioPlayer.tsx[132-148]
- src/components/AudioPlayer.tsx[180-184]

### Suggested approach
1. Add a `resumeTimeoutRef` to store the timeout id; clear it:
   - before scheduling a new timeout
   - inside the unmount cleanup effect
2. Add an `isUnmountedRef` (or `isMountedRef`) set in the unmount cleanup and check it before:
   - calling `setIsPlaying`/`setCurrentSegment`
   - calling `playSegment` from delayed callbacks
   - handling `audio.play().catch(...)`

This ensures the PR guarantee (“stop audio on navigation”) holds even if the user navigates during pending async operations.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// Play a specific segment
const playSegment = useCallback(
(segmentIndex: number) => {
Expand All @@ -140,6 +155,7 @@ export function AudioPlayer({
audioRef.current = audio;

const onEnded = () => {
if (!isMountedRef.current) return;
const next = getNextSelectedSegment(segmentIndex);
if (next !== null) {
setCurrentSegment(next);
Expand All @@ -160,6 +176,7 @@ export function AudioPlayer({
};

const onError = () => {
if (!isMountedRef.current) return;
setIsPlaying(false);
setCurrentSegment(null);
};
Expand All @@ -171,6 +188,7 @@ export function AudioPlayer({
audio.addEventListener('error', onError);

audio.play().catch((error) => {
if (!isMountedRef.current) return;
console.error('Failed to play audio:', error);
setIsPlaying(false);
setCurrentSegment(null);
Expand Down Expand Up @@ -226,7 +244,12 @@ export function AudioPlayer({

// Resume playing if it was playing before
if (wasPlaying && wasSegment !== null) {
setTimeout(() => {
if (resumeTimeoutRef.current !== null) {
clearTimeout(resumeTimeoutRef.current);
}
resumeTimeoutRef.current = setTimeout(() => {
resumeTimeoutRef.current = null;
if (!isMountedRef.current) return;
setIsPlaying(true);
playSegment(wasSegment);
}, 100);
Expand Down