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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user