diff --git a/webui/src/mic/MicApp.jsx b/webui/src/mic/MicApp.jsx index 1e445e79..0cbeff2a 100644 --- a/webui/src/mic/MicApp.jsx +++ b/webui/src/mic/MicApp.jsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useSocket } from '../context/SocketContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx'; import useUserIdentitySync from '../hooks/useUserIdentitySync.js'; import { RESTART_DELAY_MS } from '../lib/whepPlayback.js'; import { waitForIceGatheringComplete } from '../components/vip/VipAudioUploadCard/whipTransport.js'; +import useMicPtt from './useMicPtt.js'; export default function MicApp() { const socket = useSocket(); @@ -11,6 +12,16 @@ export default function MicApp() { const local = useSessionSelector((state) => state.session?.isLocalNetwork); const enabled = useSessionSelector((state) => state.session?.features?.roomAudio); const [status, setStatus] = useState('Connecting…'); + const [pttMode, setPttMode] = useState(false); + const available = Boolean(connected && local && enabled); + const { held, setPointerHeld, setButtonKeyHeld, keyLabel } = useMicPtt(available && pttMode); + const transmitting = available && (!pttMode || held); + const mediaRef = useRef(null); + const transmittingRef = useRef(transmitting); + useLayoutEffect(() => { + transmittingRef.current = transmitting; + mediaRef.current?.getAudioTracks().forEach((track) => { track.enabled = transmitting; }); + }, [transmitting]); useUserIdentitySync(); useEffect(() => { if (!connected || !local || !enabled) return undefined; @@ -61,6 +72,9 @@ export default function MicApp() { const captured = await navigator.mediaDevices.getUserMedia({ audio: true }); if (!isCurrent(current)) { captured.getTracks().forEach((track) => track.stop()); return; } media = captured; + mediaRef.current = media; + // New capture and reconnect attempts must respect the current PTT state immediately. + media.getAudioTracks().forEach((track) => { track.enabled = transmittingRef.current; }); media.getAudioTracks().forEach((track) => { track.onended = () => retry(attempt, 'Microphone disconnected. Retrying…'); }); } const source = await ack('roomAudio:start'); @@ -103,9 +117,55 @@ export default function MicApp() { clearTimeout(retryTimer); closeAttempt(); media?.getTracks().forEach((track) => { track.onended = null; track.stop(); }); + mediaRef.current = null; if (socket.connected) socket.emit('roomAudio:stop', {}); }; }, [socket, connected, local, enabled]); const message = !connected ? 'Connecting…' : local === false ? 'This page is available to local visitors only.' : !enabled ? 'Room audio is disabled.' : status; - return Room microphone{message}; + return ( + + + Room microphone + {message} + + {[false, true].map((ptt) => ( + setPttMode(ptt)}> + {ptt ? 'Push to talk' : 'Open mic'} + + ))} + + {pttMode && ( + <> + { + if (event.button !== 0 || !event.isPrimary) return; + event.currentTarget.setPointerCapture(event.pointerId); + setPointerHeld(true); + }} + onPointerUp={() => setPointerHeld(false)} + onPointerCancel={() => setPointerHeld(false)} + onLostPointerCapture={() => setPointerHeld(false)} + onContextMenu={(event) => event.preventDefault()} + onKeyDown={(event) => { + if (event.key !== ' ' && event.key !== 'Enter') return; + event.preventDefault(); + setButtonKeyHeld(true); + }} + onKeyUp={(event) => { + if (event.key !== ' ' && event.key !== 'Enter') return; + event.preventDefault(); + setButtonKeyHeld(false); + }} + onBlur={() => setButtonKeyHeld(false)}> + {held ? 'Talking…' : 'Hold to talk'} + + Hold the button, {keyLabel}, or your configured controller PTT control. + > + )} + + + ); } diff --git a/webui/src/mic/useMicPtt.js b/webui/src/mic/useMicPtt.js new file mode 100644 index 00000000..59e308c2 --- /dev/null +++ b/webui/src/mic/useMicPtt.js @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSettingsNamespace } from '../settings/index.js'; +import { DEFAULT_KEYMAP } from '../controls/constants.js'; +import { formatKeyLabel, normalizeKeymapEntries, tokensForEvent } from '../controls/keymapUtils.js'; +import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; +import { computeGamepadOutputs, resolveGamepadProfile } from '../controls/inputs/gamepadBindings.js'; +import { subscribeGamepadHub } from '../controls/inputs/gamepadHub.js'; +import { isTextInputElement, isTextEntryActive } from '../controls/inputs/inputFocusUtils.js'; +import { isKeyboardCaptureLocked } from '../controls/inputs/keyboardCaptureLock.js'; +import { isControllerControlLocked } from '../controls/inputs/controllerRuntime.js'; + +export default function useMicPtt(enabled) { + const { value: controls } = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP }); + const { value: gamepad } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS); + const bindings = controls?.keymap?.micPtt ?? DEFAULT_KEYMAP.micPtt; + const tokens = useMemo(() => normalizeKeymapEntries({ micPtt: bindings }).micPtt, [bindings]); + const [keyboardHeld, setKeyboardHeld] = useState(false); + const [controllerHeld, setControllerHeld] = useState(false); + const [pointerHeld, setPointerHeld] = useState(false); + const [buttonKeyHeld, setButtonKeyHeld] = useState(false); + + useEffect(() => { + if (!enabled) return undefined; + const pressed = new Set(); + function reset() { + pressed.clear(); + setKeyboardHeld(false); + setControllerHeld(false); + setPointerHeld(false); + setButtonKeyHeld(false); + } + function keyDown(event) { + if (isKeyboardCaptureLocked() || isTextInputElement(event.target)) return; + const matching = tokensForEvent(event).filter((token) => tokens.has(token)); + if (!matching.length) return; + event.preventDefault(); + matching.forEach((token) => pressed.add(token)); + setKeyboardHeld(true); + } + function keyUp(event) { + tokensForEvent(event).forEach((token) => pressed.delete(token)); + setKeyboardHeld(pressed.size > 0); + } + function visibilityChange() { + if (document.hidden) reset(); + } + // Reuse the configured bindings and shared poller without mounting rover controls on /mic. + const unsubscribe = subscribeGamepadHub(({ pads }) => { + if (document.hidden || !document.hasFocus() || isTextEntryActive() || isControllerControlLocked()) { + setControllerHeld(false); + return; + } + const pad = pads.find((entry) => entry.instanceKey === gamepad?.activeInstanceKey) ?? pads[0]; + const profile = resolveGamepadProfile( + gamepad?.profiles?.[pad?.signature] ?? gamepad?.defaults?.profile, + GAMEPAD_PROFILE_DEFAULT, + ); + setControllerHeld(Boolean(pad && computeGamepadOutputs(pad, profile).buttons.micPtt)); + }); + window.addEventListener('keydown', keyDown); + window.addEventListener('keyup', keyUp); + window.addEventListener('blur', reset); + document.addEventListener('visibilitychange', visibilityChange); + return () => { + unsubscribe(); + window.removeEventListener('keydown', keyDown); + window.removeEventListener('keyup', keyUp); + window.removeEventListener('blur', reset); + document.removeEventListener('visibilitychange', visibilityChange); + reset(); + }; + }, [enabled, tokens, gamepad]); + + return { + held: enabled && (keyboardHeld || controllerHeld || pointerHeld || buttonKeyHeld), + setPointerHeld, + setButtonKeyHeld, + keyLabel: (Array.isArray(bindings) ? bindings : [bindings]).map(formatKeyLabel).join(' / '), + }; +}
{message}
Hold the button, {keyLabel}, or your configured controller PTT control.