// 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; /** * When true, the hotkey is registered as an OS-level shortcut and fires * even when the app isn't focused. When false (default) the binding only * fires from the window's keydown listener — so e.g. setting "M" as mute * doesn't break typing "m" everywhere else on the system. */ global: boolean; } export interface VoiceHotkeys { mute: VoiceHotkeyBinding; deafen: VoiceHotkeyBinding; /** Hang up the active call. Discord uses no default — easy to mis-fire. */ hangup: VoiceHotkeyBinding; /** Toggle outgoing screen share. */ screenShare: VoiceHotkeyBinding; /** Toggle outgoing camera. */ video: VoiceHotkeyBinding; } export type VoiceHotkeyKind = keyof VoiceHotkeys; const DEFAULTS: VoiceHotkeys = { mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false }, deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false }, hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false }, screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false }, video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: 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, global: typeof b.global === 'boolean' ? b.global : fallback.global, }; } 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), hangup: validateBinding(parsed.hangup, DEFAULTS.hangup), screenShare: validateBinding(parsed.screenShare, DEFAULTS.screenShare), video: validateBinding(parsed.video, DEFAULTS.video), }; 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; }