fix(calls): release AFK-monitor mic capture when muted (N95)

useAfkAutoMute opened its own getUserMedia capture for the whole call and only
stopped it on unmount, so the OS recording indicator stayed lit even when the
user was muted. The capture is now gated on the reactive mic-on state: it runs
only while unmuted (there's nothing to auto-mute when already muted), so muting
tears down the stream and clears the indicator, and unmuting re-acquires it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 21:15:31 -04:00
parent 5204766276
commit 1778cd0009
+27 -23
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call'; import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
@@ -9,17 +9,25 @@ const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500; const CHECK_INTERVAL_MS = 500;
/** /**
* Monitors microphone audio while in a call. If the mic stays active but * 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 * silent for longer than the configured timeout, the mic is muted and a toast
* toast is shown. Cleans up its own AudioContext and stream on unmount. * 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 { export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const { microphone } = useCallControlState(callEmbed?.control);
useEffect(() => { 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 stream: MediaStream | undefined;
let audioCtx: AudioContext | 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); const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) { if (rms > SILENCE_RMS_THRESHOLD) {
// Audio detected — reset the silence timer // Audio detected — reset the silence timer.
silenceStart = null; silenceStart = null;
} else if (callEmbed.control.microphone) { } else if (silenceStart === null) {
// Mic is on but silent — start or advance the timer // Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
if (silenceStart === null) silenceStart = Date.now(); silenceStart = Date.now();
else if (Date.now() - silenceStart >= timeoutMs) { } else if (Date.now() - silenceStart >= timeoutMs) {
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
setToast({ setToast({
id: `afk-mute-${Date.now()}`, id: `afk-mute-${Date.now()}`,
displayName: 'Lotus Chat', displayName: 'Lotus Chat',
body: 'Your microphone was muted after inactivity.', body: 'Your microphone was muted after inactivity.',
roomName: 'Voice call', roomName: 'Voice call',
roomId: callEmbed.roomId, roomId: callEmbed.roomId,
}); });
silenceStart = null;
}
} else {
// Mic is already muted — don't count silence
silenceStart = null; silenceStart = null;
} }
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
@@ -79,5 +83,5 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
stream?.getTracks().forEach((t) => t.stop()); stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined); audioCtx?.close().catch(() => undefined);
}; };
}, [callEmbed, enabled, timeoutMinutes, setToast]); }, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
} }