diff --git a/src/app/features/call/CallSoundboard.tsx b/src/app/features/call/CallSoundboard.tsx index 56afb267f..3fcdf60c1 100644 --- a/src/app/features/call/CallSoundboard.tsx +++ b/src/app/features/call/CallSoundboard.tsx @@ -67,9 +67,11 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { // C-L6: the play() flow schedules a 30s safety timeout that clears playingKey; // guard those setState calls against the component unmounting first. const mountedRef = useRef(true); + const safetyTimerRef = useRef(undefined); useEffect( () => () => { mountedRef.current = false; + if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current); }, [], ); @@ -96,7 +98,17 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { if (playingKey) return; // one at a time (fork also enforces this) setPlayingKey(flat.key); setError(undefined); + // Per-play timer token: `done` clears its OWN timer by identity, so a + // stale done() from a prior clip can't disarm a newer clip's safety timer + // (which — since a rejected audio.play() fires neither ended nor error — + // is sometimes the only thing that unsticks the playingKey guard). + let myTimer: number | undefined; const done = () => { + if (myTimer !== undefined) { + window.clearTimeout(myTimer); + if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined; + myTimer = undefined; + } if (!mountedRef.current) return; setPlayingKey((k) => (k === flat.key ? undefined : k)); }; @@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { if (audio) { audio.addEventListener('ended', done, { once: true }); audio.addEventListener('error', done, { once: true }); + // Safety: clear the guard even if the audio never signals end. + myTimer = window.setTimeout(done, 30_000); + safetyTimerRef.current = myTimer; } else { done(); } - // Safety: clear the guard even if the audio never signals end. - window.setTimeout(done, 30_000); } catch { setError('Could not play that clip.'); done(); diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index 0e6c5efef..c6ac4afb1 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState { useEffect(() => { if (!navigator.permissions) { setState('unknown'); - return; + return undefined; } + let cancelled = false; + let permStatus: PermissionStatus | undefined; navigator.permissions .query({ name: 'microphone' as unknown as PermissionDescriptor['name'] }) .then((result) => { + if (cancelled) return; + permStatus = result; setState(result.state as MediaPermState); - result.onchange = () => setState(result.state as MediaPermState); + result.onchange = () => { + if (!cancelled) setState(result.state as MediaPermState); + }; }) - .catch(() => setState('unknown')); + .catch(() => { + if (!cancelled) setState('unknown'); + }); + // Detach the onchange handler on unmount so it can't setState afterward (and + // so the PermissionStatus doesn't retain the callback). + return () => { + cancelled = true; + if (permStatus) permStatus.onchange = null; + }; }, []); return state;