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:
@@ -48,6 +48,38 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<boolean> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user