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 { 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]);
}