diff --git a/webui/src/mic/MicApp.jsx b/webui/src/mic/MicApp.jsx
index 0cbeff2a..a83591ad 100644
--- a/webui/src/mic/MicApp.jsx
+++ b/webui/src/mic/MicApp.jsx
@@ -4,9 +4,20 @@ 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';
+import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager, useControlSelector } from '../controls/index.js';
+import ControlHint from '../components/ControlHint/index.jsx';
export default function MicApp() {
+ return (
+
+
+
+
+
+ );
+}
+
+function MicPage() {
const socket = useSocket();
const connected = useSessionSelector((state) => state.connected);
const local = useSessionSelector((state) => state.session?.isLocalNetwork);
@@ -14,7 +25,10 @@ export default function MicApp() {
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 pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
+ const [pointerHeld, setPointerHeld] = useState(false);
+ const [buttonKeyHeld, setButtonKeyHeld] = useState(false);
+ const held = available && pttMode && (pttActive || pointerHeld || buttonKeyHeld);
const transmitting = available && (!pttMode || held);
const mediaRef = useRef(null);
const transmittingRef = useRef(transmitting);
@@ -22,6 +36,22 @@ export default function MicApp() {
transmittingRef.current = transmitting;
mediaRef.current?.getAudioTracks().forEach((track) => { track.enabled = transmitting; });
}, [transmitting]);
+ useEffect(() => {
+ // Pointer capture can outlive focus; release the page button when leaving the page.
+ function releaseButton() {
+ setPointerHeld(false);
+ setButtonKeyHeld(false);
+ }
+ function visibilityChange() {
+ if (document.hidden) releaseButton();
+ }
+ window.addEventListener('blur', releaseButton);
+ document.addEventListener('visibilitychange', visibilityChange);
+ return () => {
+ window.removeEventListener('blur', releaseButton);
+ document.removeEventListener('visibilitychange', visibilityChange);
+ };
+ }, []);
useUserIdentitySync();
useEffect(() => {
if (!connected || !local || !enabled) return undefined;
@@ -131,7 +161,11 @@ export default function MicApp() {
{[false, true].map((ptt) => (
))}
@@ -162,7 +196,7 @@ export default function MicApp() {
onBlur={() => setButtonKeyHeld(false)}>
{held ? 'Talking…' : 'Hold to talk'}
-
Hold the button, {keyLabel}, or your configured controller PTT control.
+ Hold the button or to talk.
>
)}
diff --git a/webui/src/mic/useMicPtt.js b/webui/src/mic/useMicPtt.js
deleted file mode 100644
index 59e308c2..00000000
--- a/webui/src/mic/useMicPtt.js
+++ /dev/null
@@ -1,80 +0,0 @@
-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(' / '),
- };
-}