172 lines
6.0 KiB
TypeScript
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;
|
|
}
|