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
+165
View File
@@ -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<Listener>();
let cached: VoiceHotkeys | null = null;
function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotkeyBinding {
if (!raw || typeof raw !== 'object') return fallback;
const b = raw as Partial<VoiceHotkeyBinding>;
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<VoiceHotkeys>;
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<VoiceHotkeyBinding>,
): 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;
}