feat(call): mute + deafen global hotkeys with chord support (group E)

- New voiceHotkeys.ts storage module. Stores per-action bindings with
  modifier flags (Ctrl/Shift/Alt) so Discord-style chords like
  Ctrl+Shift+M work. Defaults match Discord — Ctrl+Shift+M mute,
  Ctrl+Shift+D deafen — but ship disabled to avoid surprise collisions.
- globalShortcut.ts grows registerGlobalShortcutPress /
  unregisterGlobalShortcut helpers that accept pre-formatted Tauri
  accelerator strings, since voiceHotkey chords can't be expressed by
  the existing codeToShortcut path (PTT-only single-key).
- CallContext registers the chords OS-wide while a call is active
  (connected | reconnecting) so the hotkeys work from any focused
  window. A window-keydown fallback handles the non-Tauri / denied
  registration case. Both unregister on call end.
- SettingsPage adds VoiceHotkeyControls (mute + deafen variants)
  with a chord-capture button that waits past modifier-only presses
  and binds to the first real key. Labels render as "Ctrl+Shift+M"
  consistently with the in-call PTT hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 20:07:09 +02:00
parent bc8a7c5a32
commit eb8f702576
4 changed files with 388 additions and 0 deletions
+88
View File
@@ -34,10 +34,19 @@ import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
isTauriRuntime,
registerGlobalShortcutPress,
registerPttShortcut,
unregisterGlobalShortcut,
unregisterPttShortcut,
} from '../lib/globalShortcut';
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
bindingToTauriShortcut,
eventMatchesBinding,
getVoiceHotkeys,
subscribeVoiceHotkeys,
type VoiceHotkeys,
} from '../lib/voiceHotkeys';
import {
getAudioQualityParams,
getAudioSettings,
@@ -1331,6 +1340,85 @@ export function CallProvider({ children }: { children: ReactNode }) {
};
}, [state.kind]);
// --- Mute / Deafen global hotkeys --------------------------------------
// Discord-parity: both toggles respond to a user-configurable chord
// (default Ctrl+Shift+M / Ctrl+Shift+D, disabled until the user opts in).
// Registered only while a call is active so the shortcuts don't intercept
// typing outside of calls. Under Tauri we register an OS-level chord so
// mute/deafen work from any focused window; the window keydown listener
// is the fallback for the web build + when the global register fails
// (another app owns the chord).
useEffect(() => {
if (
state.kind !== 'connected' &&
state.kind !== 'reconnecting'
) {
return;
}
let settings: VoiceHotkeys = getVoiceHotkeys();
let registered: { mute: string | null; deafen: string | null } = {
mute: null,
deafen: null,
};
const fire = (kind: 'mute' | 'deafen') => {
if (kind === 'mute') toggleMute();
else toggleDeafen();
};
const onKey = (e: KeyboardEvent) => {
if (eventMatchesBinding(settings.mute, e)) {
e.preventDefault();
fire('mute');
return;
}
if (eventMatchesBinding(settings.deafen, e)) {
e.preventDefault();
fire('deafen');
return;
}
};
window.addEventListener('keydown', onKey);
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
const desired = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
};
for (const kind of ['mute', 'deafen'] as const) {
const want = desired[kind];
const have = registered[kind];
if (want === have) continue;
if (have) {
void unregisterGlobalShortcut(have);
registered = { ...registered, [kind]: null };
}
if (want) {
// Tag the closure so React's stale-state trap doesn't bite —
// `fire` is stable (defined above), and `kind` is captured by
// value.
const thisKind = kind;
void registerGlobalShortcutPress(want, () => fire(thisKind));
registered = { ...registered, [kind]: want };
}
}
};
syncGlobalShortcuts();
const unsub = subscribeVoiceHotkeys((next) => {
settings = next;
syncGlobalShortcuts();
});
return () => {
unsub();
window.removeEventListener('keydown', onKey);
if (registered.mute) void unregisterGlobalShortcut(registered.mute);
if (registered.deafen) void unregisterGlobalShortcut(registered.deafen);
};
}, [state.kind, toggleMute, toggleDeafen]);
// --- Outgoing-channel: listen for accept/reject on our own invite ------
// and incoming invites from peers.
useEffect(() => {