Files
ChatApp/apps/desktop/src/lib/voiceHotkeys.ts
T

172 lines
6.0 KiB
TypeScript

// 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<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,
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<VoiceHotkeys>;
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<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;
}