From eb8f7025764038de96c8fa260b4dcc95157f9269 Mon Sep 17 00:00:00 2001 From: byGalax Date: Wed, 22 Apr 2026 20:07:09 +0200 Subject: [PATCH] feat(call): mute + deafen global hotkeys with chord support (group E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/desktop/src/context/CallContext.tsx | 88 ++++++++++++ apps/desktop/src/lib/globalShortcut.ts | 32 +++++ apps/desktop/src/lib/voiceHotkeys.ts | 165 +++++++++++++++++++++++ apps/desktop/src/pages/SettingsPage.tsx | 103 ++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 apps/desktop/src/lib/voiceHotkeys.ts diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index a9a94ae..7a79f2f 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -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(() => { diff --git a/apps/desktop/src/lib/globalShortcut.ts b/apps/desktop/src/lib/globalShortcut.ts index a5ab1d5..e958305 100644 --- a/apps/desktop/src/lib/globalShortcut.ts +++ b/apps/desktop/src/lib/globalShortcut.ts @@ -48,6 +48,38 @@ export async function unregisterPttShortcut(code: string): Promise { } } +// Press-only global shortcut (for toggles like Mute/Deafen). Accepts an +// already-formatted accelerator string (e.g. "CommandOrControl+Shift+M") +// since these bindings may include modifier chords — the KeyboardEvent.code +// variant used by PTT can't express that. +export async function registerGlobalShortcutPress( + shortcut: string, + onPress: () => void, +): Promise { + try { + if (await isRegistered(shortcut)) { + await unregister(shortcut); + } + await register(shortcut, (event: ShortcutEvent) => { + if (event.state === 'Pressed') onPress(); + }); + return true; + } catch (err: unknown) { + console.warn('registerGlobalShortcutPress failed', { shortcut, err }); + return false; + } +} + +export async function unregisterGlobalShortcut(shortcut: string): Promise { + try { + if (await isRegistered(shortcut)) { + await unregister(shortcut); + } + } catch (err: unknown) { + console.warn('unregisterGlobalShortcut failed', { shortcut, err }); + } +} + // Detects whether we're running under Tauri. When running in a pure web // preview (vite dev in a browser without Tauri), importing the plugin still // works but calls fall through to window.__TAURI_INTERNALS__ which doesn't diff --git a/apps/desktop/src/lib/voiceHotkeys.ts b/apps/desktop/src/lib/voiceHotkeys.ts new file mode 100644 index 0000000..bfd963d --- /dev/null +++ b/apps/desktop/src/lib/voiceHotkeys.ts @@ -0,0 +1,165 @@ +// Toggle-style voice hotkeys (mute / deafen). Distinct from PTT (hold-style +// single-key). Keeps the Discord muscle-memory defaults — Ctrl+Shift+M for +// mute, Ctrl+Shift+D for deafen — but ships disabled so they never collide +// with something else on first run. +// +// Chord-capable: each binding stores a base key (KeyboardEvent.code) plus +// modifier flags. The keyLabel field is pre-rendered so UI and in-call hints +// don't have to derive it on every render. + +import { keyCodeToLabel } from './pttSettings'; + +const STORAGE_KEY = 'chatapp.voiceHotkeys.v1'; + +export interface VoiceHotkeyBinding { + /** KeyboardEvent.code of the base key. */ + key: string; + /** Pre-rendered label. Includes modifier prefixes, e.g. "Ctrl+Shift+M". */ + keyLabel: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + enabled: boolean; +} + +export interface VoiceHotkeys { + mute: VoiceHotkeyBinding; + deafen: VoiceHotkeyBinding; +} + +export type VoiceHotkeyKind = keyof VoiceHotkeys; + +const DEFAULTS: VoiceHotkeys = { + mute: { + key: 'KeyM', + keyLabel: 'Ctrl+Shift+M', + ctrl: true, + shift: true, + alt: false, + enabled: false, + }, + deafen: { + key: 'KeyD', + keyLabel: 'Ctrl+Shift+D', + ctrl: true, + shift: true, + alt: false, + enabled: false, + }, +}; + +type Listener = (s: VoiceHotkeys) => void; +const listeners = new Set(); + +let cached: VoiceHotkeys | null = null; + +function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotkeyBinding { + if (!raw || typeof raw !== 'object') return fallback; + const b = raw as Partial; + return { + key: typeof b.key === 'string' && b.key ? b.key : fallback.key, + keyLabel: typeof b.keyLabel === 'string' && b.keyLabel ? b.keyLabel : fallback.keyLabel, + ctrl: typeof b.ctrl === 'boolean' ? b.ctrl : fallback.ctrl, + shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift, + alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt, + enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled, + }; +} + +function read(): VoiceHotkeys { + if (cached) return cached; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + cached = DEFAULTS; + return cached; + } + const parsed = JSON.parse(raw) as Partial; + cached = { + mute: validateBinding(parsed.mute, DEFAULTS.mute), + deafen: validateBinding(parsed.deafen, DEFAULTS.deafen), + }; + return cached; + } catch { + cached = DEFAULTS; + return cached; + } +} + +function write(s: VoiceHotkeys): void { + cached = s; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + } catch { + /* quota / private mode */ + } + for (const l of listeners) l(s); +} + +export function getVoiceHotkeys(): VoiceHotkeys { + return read(); +} + +export function updateVoiceHotkey( + kind: VoiceHotkeyKind, + patch: Partial, +): VoiceHotkeys { + const cur = read(); + const next: VoiceHotkeys = { + ...cur, + [kind]: { + ...cur[kind], + ...patch, + }, + }; + // Keep the label in sync with the key + modifier flags so callers don't + // have to remember to pass keyLabel too. + next[kind].keyLabel = renderBindingLabel(next[kind]); + write(next); + return next; +} + +export function subscribeVoiceHotkeys(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +// Derives "Ctrl+Shift+M" from a binding. The base key is normalised via +// keyCodeToLabel so "KeyM" → "M" etc, matching the PTT label rendering. +export function renderBindingLabel(b: VoiceHotkeyBinding): string { + const parts: string[] = []; + if (b.ctrl) parts.push('Ctrl'); + if (b.shift) parts.push('Shift'); + if (b.alt) parts.push('Alt'); + parts.push(keyCodeToLabel(b.key)); + return parts.join('+'); +} + +// Tauri global-shortcut accelerator string format. `CommandOrControl` maps +// to Cmd on macOS and Ctrl on Windows/Linux so the same binding works on +// every platform without platform-specific storage. +export function bindingToTauriShortcut(b: VoiceHotkeyBinding): string { + const base = b.key.startsWith('Key') + ? b.key.slice(3) + : b.key.startsWith('Digit') + ? b.key.slice(5) + : b.key; + const parts: string[] = []; + if (b.ctrl) parts.push('CommandOrControl'); + if (b.shift) parts.push('Shift'); + if (b.alt) parts.push('Alt'); + parts.push(base); + return parts.join('+'); +} + +// Check if a browser KeyboardEvent matches a binding exactly (including +// modifier state). Used by the window-level fallback listener when the +// Tauri global-shortcut registration isn't available. +export function eventMatchesBinding(b: VoiceHotkeyBinding, e: KeyboardEvent): boolean { + if (!b.enabled) return false; + if (e.code !== b.key) return false; + if (e.ctrlKey !== b.ctrl && e.metaKey !== b.ctrl) return false; + if (e.shiftKey !== b.shift) return false; + if (e.altKey !== b.alt) return false; + return true; +} diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index 71517cb..a42479a 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -25,6 +25,13 @@ import { subscribePttSettings, updatePttSettings, } from '../lib/pttSettings'; +import { + getVoiceHotkeys, + subscribeVoiceHotkeys, + updateVoiceHotkey, + type VoiceHotkeyKind, + type VoiceHotkeys, +} from '../lib/voiceHotkeys'; import { AUDIO_QUALITY_ORDER, type AudioQuality, @@ -163,6 +170,12 @@ export function SettingsPage() {
+
+ +
+
+ +
@@ -263,6 +276,96 @@ function PttControls() { ); } +function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) { + const { t } = useTranslation(['app']); + const [hotkeys, setHotkeys] = useState(() => getVoiceHotkeys()); + const [capturing, setCapturing] = useState(false); + + useEffect(() => subscribeVoiceHotkeys(setHotkeys), []); + + useEffect(() => { + if (!capturing) return; + const onKey = (e: KeyboardEvent) => { + // Modifier-only presses shouldn't bind — wait for a real key to + // arrive. Escape aborts the capture. + if (e.code === 'Escape') { + e.preventDefault(); + setCapturing(false); + return; + } + if ( + e.code === 'ControlLeft' || + e.code === 'ControlRight' || + e.code === 'ShiftLeft' || + e.code === 'ShiftRight' || + e.code === 'AltLeft' || + e.code === 'AltRight' || + e.code === 'MetaLeft' || + e.code === 'MetaRight' + ) { + return; + } + e.preventDefault(); + updateVoiceHotkey(kind, { + key: e.code, + ctrl: e.ctrlKey || e.metaKey, + shift: e.shiftKey, + alt: e.altKey, + }); + setCapturing(false); + }; + window.addEventListener('keydown', onKey, { capture: true }); + return () => window.removeEventListener('keydown', onKey, { capture: true }); + }, [capturing, kind]); + + const binding = hotkeys[kind]; + const toggleLabel = + kind === 'mute' + ? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }) + : t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }); + const toggleHint = + kind === 'mute' + ? t('app:settings.hotkey_mute_hint', { + defaultValue: + 'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.', + }) + : t('app:settings.hotkey_deafen_hint', { + defaultValue: + 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).', + }); + + return ( + <> + updateVoiceHotkey(kind, { enabled: v })} + /> + + + + + ); +} + function CallE2EEControls() { const { t } = useTranslation(['app']); const [cfg, setCfg] = useState(() => getCallE2EESettings());