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'; import { useMatrixClient } from './useMatrixClient'; const CHECK_INTERVAL_MS = 500; /** * Monitors microphone activity while in a call. If the mic stays unmuted but * the user is not speaking for longer than the configured timeout, the mic is * muted and a toast is shown. * * [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream * (getLotusParticipants) — i.e. the VAD state of the user's ACTUAL published * track on their SELECTED input device. The previous implementation opened its * own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic * (not necessarily the device EC publishes from): it could measure silence * while the user spoke on a different device (auto-muting an active speaker) and * lit a second OS microphone indicator. Sourcing from the fork removes both * problems and needs no extra capture. * * If the fork hasn't reported call-state yet (getLotusParticipants() === null — * e.g. plain EC, or immediately after join), we cannot tell whether the user is * publishing, so we fail SAFE and never auto-mute during that window. */ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { const mx = useMatrixClient(); const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const setToast = useSetAtom(toastQueueAtom); const { microphone } = useCallControlState(callEmbed?.control); useEffect(() => { // Only monitor while in a call, enabled, AND unmuted — there is nothing to // auto-mute once you are already muted. if (!callEmbed || !enabled || !microphone) return undefined; const localUserId = mx.getSafeUserId(); const timeoutMs = timeoutMinutes * 60 * 1000; let silenceStart: number | null = null; let active = true; // undefined = fork hasn't reported call-state yet (can't tell — fail safe). const isLocalSpeaking = (): boolean | undefined => { const participants = callEmbed.getLotusParticipants(); // null = fork not reported; [] = malformed/spurious payload (CallEmbed // stores [] for a non-array). You are ALWAYS present in your own joined // call, so an empty list means "no usable data", NOT "silent" — matching // useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the // timer mute an active speaker. Fail safe on both. if (participants === null || participants.length === 0) return undefined; return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking); }; const intervalId = setInterval(() => { if (!active) return; const speaking = isLocalSpeaking(); if (speaking === undefined) { // No usable signal — don't risk muting an active speaker. silenceStart = null; } else if (speaking) { // Voice detected on the published track — 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); return () => { active = false; clearInterval(intervalId); }; }, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]); }