84 lines
3.0 KiB
TypeScript
84 lines
3.0 KiB
TypeScript
|
|
import { useEffect } from 'react';
|
||
|
|
import { useSetAtom } from 'jotai';
|
||
|
|
import { CallEmbed } 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 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.
|
||
|
|
*/
|
||
|
|
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||
|
|
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||
|
|
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||
|
|
const setToast = useSetAtom(toastQueueAtom);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!callEmbed || !enabled) return;
|
||
|
|
|
||
|
|
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 (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
|
||
|
|
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]);
|
||
|
|
}
|