fix(calls): PTT, deafen hotkey and AFK auto-mute live for the whole call
They were wired inside CallControls, which only renders while the call room is selected, so navigating away (PiP) or opening the in-call chat on mobile silently disabled all three — AFK auto-mute exactly when it mattered. Move them into useCallHotkeys + useAfkAutoMute mounted from the embed-lifetime CallUtils, gated on joined; CallControls keeps only the PTT chip (pttActiveAtom). Also: window blur/focus release the mic only while a PTT key is actually held, so a deliberate hands-free unmute survives a click into the iframe (#27); iframe-side listeners re-bind on the iframe load event so they survive an EC reload (#60). The #23 modifier/interactive guards are preserved and unit-tested. Fixes #9 Fixes #27 Fixes #60 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -45,6 +45,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
@@ -609,9 +611,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
|
||||
// embed's whole lifetime, rather than in CallControls (which only renders
|
||||
// while the call room is selected) — so they keep working in PiP and behind
|
||||
// the mobile in-call chat. Both are gated on `joined`.
|
||||
useCallHotkeys(embed, joined);
|
||||
useAfkAutoMute(joined ? embed : undefined);
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallThemeSync(embed);
|
||||
@@ -1146,7 +1154,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -39,7 +39,7 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -88,8 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||
useCallControlState(callEmbed.control);
|
||||
|
||||
useAfkAutoMute(callEmbed);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -102,8 +100,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
}, [shareConfirm]);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
|
||||
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
|
||||
// (this component only renders while the call room is selected). Only the
|
||||
// visual PTT chip remains here.
|
||||
const pttActive = useAtomValue(pttActiveAtom);
|
||||
|
||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||
// users don't click dead buttons. Absent/true = allowed.
|
||||
@@ -116,28 +118,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const showCamera = cameraAllowed || video;
|
||||
const showScreenshare = screenshareAllowed || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => {
|
||||
microphoneRef.current = microphone;
|
||||
}, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||||
const pttModeRef = useRef(pttMode);
|
||||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
micBeforePTTRef.current = microphoneRef.current;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||||
micBeforePTTRef.current = null;
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, callEmbed]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
@@ -163,138 +143,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
);
|
||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||
|
||||
const pttActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pttMode) return;
|
||||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
// [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't
|
||||
// hijack OS/app chords (e.g. Cmd+Space) that happen to share the code.
|
||||
// Shift is allowed through — Shift+Space is a harmless combo for PTT.
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isEditable(target)) return;
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!isInteractive(target)) e.preventDefault();
|
||||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||
callEmbed.control.pttActive = true;
|
||||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||||
pttActiveRef.current = true;
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onBlur = () => {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onFocus = () => {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('focus', onFocus);
|
||||
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.addEventListener('keyup', onKeyUp);
|
||||
iframeWindow?.addEventListener('blur', onBlur);
|
||||
iframeWindow?.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.removeEventListener('keyup', onKeyUp);
|
||||
iframeWindow?.removeEventListener('blur', onBlur);
|
||||
iframeWindow?.removeEventListener('focus', onFocus);
|
||||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
}
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
}, [pttMode, pttKey, callEmbed]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== deafenKey) return;
|
||||
if (e.repeat) return;
|
||||
// [Gitea #23] Ignore the deafen key with any modifier held — with the
|
||||
// default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that
|
||||
// shouldn't also toggle deafen (and previously got preventDefault()ed).
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
callEmbed.control.toggleSound();
|
||||
};
|
||||
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
||||
// inside the iframe (mirrors the PTT binding above).
|
||||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [callEmbed, deafenKey]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
|
||||
|
||||
const key = (
|
||||
code: string,
|
||||
mods: Partial<Record<'repeat' | 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey', boolean>> = {},
|
||||
) => ({
|
||||
code,
|
||||
repeat: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...mods,
|
||||
});
|
||||
|
||||
test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => {
|
||||
assert.equal(isPttKeyDown(key('Space'), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false);
|
||||
});
|
||||
|
||||
test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => {
|
||||
assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true);
|
||||
assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
/**
|
||||
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
|
||||
* the lifetime of the embed) and read by the PTT chip in CallControls, which
|
||||
* only renders while the call room is selected.
|
||||
*/
|
||||
export const pttActiveAtom = atom(false);
|
||||
|
||||
type KeyLike = {
|
||||
code: string;
|
||||
repeat: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a keydown should engage push-to-talk for `pttKey`.
|
||||
* [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't hijack
|
||||
* OS/app chords (e.g. Cmd+Space) that happen to share the code. Shift is
|
||||
* allowed through — Shift+Space is a harmless combo for PTT.
|
||||
*/
|
||||
export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean =>
|
||||
e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey;
|
||||
|
||||
/**
|
||||
* Whether a keydown should toggle deafen for `deafenKey`.
|
||||
* [Gitea #23] Ignore the deafen key with any modifier held — with the default
|
||||
* 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that shouldn't also
|
||||
* toggle deafen (and previously got preventDefault()ed).
|
||||
*/
|
||||
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
|
||||
e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
|
||||
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bind listeners to the EC iframe's window and keep them bound across document
|
||||
* reloads. [Gitea #60] Listeners registered on a contentWindow are discarded
|
||||
* when that window navigates to a new document (crash recovery, in-widget
|
||||
* navigation), so re-run `bind` from the iframe's `load` event and detach the
|
||||
* previous set. Returns a cleanup that detaches everything.
|
||||
*/
|
||||
const bindIframeWindow = (
|
||||
iframe: HTMLIFrameElement,
|
||||
bind: (win: Window) => () => void,
|
||||
): (() => void) => {
|
||||
let unbind: (() => void) | undefined;
|
||||
const attach = () => {
|
||||
unbind?.();
|
||||
const win = iframe.contentWindow;
|
||||
unbind = win ? bind(win) : undefined;
|
||||
};
|
||||
attach();
|
||||
iframe.addEventListener('load', attach);
|
||||
return () => {
|
||||
iframe.removeEventListener('load', attach);
|
||||
unbind?.();
|
||||
unbind = undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Push-to-talk and deafen hotkeys for the active call.
|
||||
*
|
||||
* [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as
|
||||
* long as the embed — not only while the call room is the selected room. Before
|
||||
* this lived in CallControls, so navigating away (PiP) or opening the in-call
|
||||
* chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so
|
||||
* nothing is sent over the widget transport before EC's handler mounts.
|
||||
*/
|
||||
export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void {
|
||||
const embed = joined ? callEmbed : undefined;
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const { microphone } = useCallControlState(embed?.control);
|
||||
const setPttActive = useSetAtom(pttActiveAtom);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => {
|
||||
microphoneRef.current = microphone;
|
||||
}, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||||
const pttModeRef = useRef(pttMode);
|
||||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (embed) {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
micBeforePTTRef.current = microphoneRef.current;
|
||||
embed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
embed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||||
micBeforePTTRef.current = null;
|
||||
}
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, embed]);
|
||||
|
||||
const pttActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed || !pttMode) return undefined;
|
||||
|
||||
const release = () => {
|
||||
embed.control.pttActive = false;
|
||||
embed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isPttKeyDown(e, pttKey)) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (isEditable(target)) return;
|
||||
if (!isInteractive(target)) e.preventDefault();
|
||||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||
embed.control.pttActive = true;
|
||||
if (!microphoneRef.current) embed.control.setMicrophone(true);
|
||||
pttActiveRef.current = true;
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
release();
|
||||
};
|
||||
// BUG-9: release a held key when focus leaves/enters so the mic can't stick
|
||||
// open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is
|
||||
// actually in progress — the mic button is still usable in PTT mode, and a
|
||||
// deliberate hands-free unmute must survive a click into the iframe/alt-tab.
|
||||
const onFocusChange = () => {
|
||||
if (pttActiveRef.current) release();
|
||||
};
|
||||
const bind = (win: Window) => {
|
||||
win.addEventListener('keydown', onKeyDown);
|
||||
win.addEventListener('keyup', onKeyUp);
|
||||
win.addEventListener('blur', onFocusChange);
|
||||
win.addEventListener('focus', onFocusChange);
|
||||
return () => {
|
||||
win.removeEventListener('keydown', onKeyDown);
|
||||
win.removeEventListener('keyup', onKeyUp);
|
||||
win.removeEventListener('blur', onFocusChange);
|
||||
win.removeEventListener('focus', onFocusChange);
|
||||
};
|
||||
};
|
||||
const unbindHost = bind(window);
|
||||
// BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe
|
||||
const unbindIframe = bindIframeWindow(embed.iframe, bind);
|
||||
return () => {
|
||||
unbindHost();
|
||||
unbindIframe();
|
||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) release();
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
}, [pttMode, pttKey, embed, setPttActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isDeafenKeyDown(e, deafenKey)) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
embed.control.toggleSound();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
||||
// inside the iframe (mirrors the PTT binding above).
|
||||
const unbindIframe = bindIframeWindow(embed.iframe, (win) => {
|
||||
win.addEventListener('keydown', onKeyDown);
|
||||
return () => win.removeEventListener('keydown', onKeyDown);
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
unbindIframe();
|
||||
};
|
||||
}, [embed, deafenKey]);
|
||||
}
|
||||
Reference in New Issue
Block a user