Files
ChatApp/apps/desktop/src/lib/globalShortcut.ts
T
byGalax eb8f702576 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>
2026-04-22 20:07:09 +02:00

159 lines
5.1 KiB
TypeScript

import {
isRegistered,
register,
type ShortcutEvent,
unregister,
} from '@tauri-apps/plugin-global-shortcut';
// Maps a KeyboardEvent.code (what our PTT settings store) into the shortcut
// string accepted by tauri-plugin-global-shortcut. The plugin follows the
// [keyboard-types] crate naming which mostly matches DOM `event.code`, but
// single-key aliases (e.g. "Space", "F5") work as-is.
export function codeToShortcut(code: string): string {
if (code.startsWith('Key')) return code.slice(3); // KeyV -> V
if (code.startsWith('Digit')) return code.slice(5); // Digit1 -> 1
// Space, F1..F24, Escape, Enter, Tab, Arrow*, etc. pass through unchanged.
return code;
}
export async function registerPttShortcut(
code: string,
onPress: () => void,
onRelease: () => void,
): Promise<boolean> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
else if (event.state === 'Released') onRelease();
});
return true;
} catch (err: unknown) {
console.warn('registerPttShortcut failed', { code, err });
return false;
}
}
export async function unregisterPttShortcut(code: string): Promise<void> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
} catch (err: unknown) {
console.warn('unregisterPttShortcut failed', { code, err });
}
}
// 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
// exist — use this guard to skip registration cleanly.
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
// --- Soundboard shortcuts --------------------------------------------------
//
// Separate from the single PTT shortcut: the soundboard needs to register
// many fire-and-forget press bindings at once, keep track of which ids own
// which accelerators so we can unregister just one, and expose conflict
// detection for the settings UI.
interface SoundShortcutRegistration {
shortcut: string;
onPress: () => void;
}
// Map of logical id (sound uuid) -> registration.
const soundRegistry = new Map<string, SoundShortcutRegistration>();
export async function registerSoundShortcut(
id: string,
code: string,
onPress: () => void,
): Promise<boolean> {
if (!isTauriRuntime()) return false;
const shortcut = codeToShortcut(code);
// Unregister any previous binding for this id first — caller may be
// re-registering after the user changed the hotkey for the same sound.
await unregisterSoundShortcut(id);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
});
soundRegistry.set(id, { shortcut, onPress });
return true;
} catch (err: unknown) {
console.warn('registerSoundShortcut failed', { id, code, err });
return false;
}
}
export async function unregisterSoundShortcut(id: string): Promise<void> {
const reg = soundRegistry.get(id);
if (!reg) return;
soundRegistry.delete(id);
if (!isTauriRuntime()) return;
try {
if (await isRegistered(reg.shortcut)) {
await unregister(reg.shortcut);
}
} catch (err: unknown) {
console.warn('unregisterSoundShortcut failed', { id, err });
}
}
export async function unregisterAllSoundShortcuts(): Promise<void> {
const ids = Array.from(soundRegistry.keys());
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
}
// Resolve a DOM code to the registry's current owner (if any). Used by the
// settings UI to surface conflicts before saving a new hotkey.
export function soundShortcutOwnerFor(code: string): string | null {
const shortcut = codeToShortcut(code);
for (const [id, reg] of soundRegistry) {
if (reg.shortcut === shortcut) return id;
}
return null;
}