Files
cinny/src/app/hooks/useAfkAutoMute.ts
T

88 lines
3.4 KiB
TypeScript
Raw Normal View History

import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast';
const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500;
/**
* 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(() => {
// 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;
let intervalId: ReturnType<typeof setInterval> | undefined;
let silenceStart: number | null = null;
let active = true;
const timeoutMs = timeoutMinutes * 60 * 1000;
navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((s) => {
if (!active) {
s.getTracks().forEach((t) => t.stop());
return;
}
stream = s;
audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Float32Array(analyser.fftSize);
intervalId = setInterval(() => {
if (!active) return;
analyser.getFloatTimeDomainData(buffer);
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.
silenceStart = null;
} 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);
})
.catch(() => undefined);
return () => {
active = false;
if (intervalId !== undefined) clearInterval(intervalId);
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
};
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
}