diff --git a/src/app/hooks/useAfkAutoMute.ts b/src/app/hooks/useAfkAutoMute.ts index 073c8ba05..f91890490 100644 --- a/src/app/hooks/useAfkAutoMute.ts +++ b/src/app/hooks/useAfkAutoMute.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useSetAtom } from 'jotai'; -import { CallEmbed } from '../plugins/call'; +import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { toastQueueAtom } from '../state/toast'; @@ -9,17 +9,25 @@ const SILENCE_RMS_THRESHOLD = 0.008; const CHECK_INTERVAL_MS = 500; /** - * Monitors microphone audio while in a call. If the mic stays active but - * silent for longer than the configured timeout, the mic is muted and a - * toast is shown. Cleans up its own AudioContext and stream on unmount. + * Monitors microphone audio while in a call. If the mic stays unmuted but + * silent for longer than the configured timeout, the mic is muted and a toast + * is shown. + * + * The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is + * unmuted — there is nothing to auto-mute once you are already muted, so + * holding the capture would keep the OS recording indicator lit even though the + * UI shows you as muted (N95). Muting therefore releases our stream; unmuting + * re-acquires it. The AudioContext + stream are also torn down on unmount. */ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const setToast = useSetAtom(toastQueueAtom); + const { microphone } = useCallControlState(callEmbed?.control); useEffect(() => { - if (!callEmbed || !enabled) return; + // Only capture while in a call, enabled, AND unmuted (see N95 note above). + if (!callEmbed || !enabled || !microphone) return undefined; let stream: MediaStream | undefined; let audioCtx: AudioContext | undefined; @@ -49,24 +57,20 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length); if (rms > SILENCE_RMS_THRESHOLD) { - // Audio detected — reset the silence timer + // Audio detected — reset the silence timer. silenceStart = null; - } else if (callEmbed.control.microphone) { - // Mic is on but silent — start or advance the timer - if (silenceStart === null) silenceStart = Date.now(); - else if (Date.now() - silenceStart >= timeoutMs) { - callEmbed.control.setMicrophone(false); - setToast({ - id: `afk-mute-${Date.now()}`, - displayName: 'Lotus Chat', - body: 'Your microphone was muted after inactivity.', - roomName: 'Voice call', - roomId: callEmbed.roomId, - }); - silenceStart = null; - } - } else { - // Mic is already muted — don't count silence + } else if (silenceStart === null) { + // Mic is unmuted (effect only runs while unmuted) but silent — start the timer. + silenceStart = Date.now(); + } else if (Date.now() - silenceStart >= timeoutMs) { + callEmbed.control.setMicrophone(false); + setToast({ + id: `afk-mute-${Date.now()}`, + displayName: 'Lotus Chat', + body: 'Your microphone was muted after inactivity.', + roomName: 'Voice call', + roomId: callEmbed.roomId, + }); silenceStart = null; } }, CHECK_INTERVAL_MS); @@ -79,5 +83,5 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { stream?.getTracks().forEach((t) => t.stop()); audioCtx?.close().catch(() => undefined); }; - }, [callEmbed, enabled, timeoutMinutes, setToast]); + }, [callEmbed, enabled, timeoutMinutes, setToast, microphone]); }