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:
@@ -34,10 +34,19 @@ import { setCallWakeLock } from '../lib/wakeLock';
|
|||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
isTauriRuntime,
|
isTauriRuntime,
|
||||||
|
registerGlobalShortcutPress,
|
||||||
registerPttShortcut,
|
registerPttShortcut,
|
||||||
|
unregisterGlobalShortcut,
|
||||||
unregisterPttShortcut,
|
unregisterPttShortcut,
|
||||||
} from '../lib/globalShortcut';
|
} from '../lib/globalShortcut';
|
||||||
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
||||||
|
import {
|
||||||
|
bindingToTauriShortcut,
|
||||||
|
eventMatchesBinding,
|
||||||
|
getVoiceHotkeys,
|
||||||
|
subscribeVoiceHotkeys,
|
||||||
|
type VoiceHotkeys,
|
||||||
|
} from '../lib/voiceHotkeys';
|
||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
@@ -1331,6 +1340,85 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [state.kind]);
|
}, [state.kind]);
|
||||||
|
|
||||||
|
// --- Mute / Deafen global hotkeys --------------------------------------
|
||||||
|
// Discord-parity: both toggles respond to a user-configurable chord
|
||||||
|
// (default Ctrl+Shift+M / Ctrl+Shift+D, disabled until the user opts in).
|
||||||
|
// Registered only while a call is active so the shortcuts don't intercept
|
||||||
|
// typing outside of calls. Under Tauri we register an OS-level chord so
|
||||||
|
// mute/deafen work from any focused window; the window keydown listener
|
||||||
|
// is the fallback for the web build + when the global register fails
|
||||||
|
// (another app owns the chord).
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
state.kind !== 'connected' &&
|
||||||
|
state.kind !== 'reconnecting'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settings: VoiceHotkeys = getVoiceHotkeys();
|
||||||
|
let registered: { mute: string | null; deafen: string | null } = {
|
||||||
|
mute: null,
|
||||||
|
deafen: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fire = (kind: 'mute' | 'deafen') => {
|
||||||
|
if (kind === 'mute') toggleMute();
|
||||||
|
else toggleDeafen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (eventMatchesBinding(settings.mute, e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
fire('mute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventMatchesBinding(settings.deafen, e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
fire('deafen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
const syncGlobalShortcuts = () => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
const desired = {
|
||||||
|
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
|
||||||
|
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
|
||||||
|
};
|
||||||
|
for (const kind of ['mute', 'deafen'] as const) {
|
||||||
|
const want = desired[kind];
|
||||||
|
const have = registered[kind];
|
||||||
|
if (want === have) continue;
|
||||||
|
if (have) {
|
||||||
|
void unregisterGlobalShortcut(have);
|
||||||
|
registered = { ...registered, [kind]: null };
|
||||||
|
}
|
||||||
|
if (want) {
|
||||||
|
// Tag the closure so React's stale-state trap doesn't bite —
|
||||||
|
// `fire` is stable (defined above), and `kind` is captured by
|
||||||
|
// value.
|
||||||
|
const thisKind = kind;
|
||||||
|
void registerGlobalShortcutPress(want, () => fire(thisKind));
|
||||||
|
registered = { ...registered, [kind]: want };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncGlobalShortcuts();
|
||||||
|
const unsub = subscribeVoiceHotkeys((next) => {
|
||||||
|
settings = next;
|
||||||
|
syncGlobalShortcuts();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
if (registered.mute) void unregisterGlobalShortcut(registered.mute);
|
||||||
|
if (registered.deafen) void unregisterGlobalShortcut(registered.deafen);
|
||||||
|
};
|
||||||
|
}, [state.kind, toggleMute, toggleDeafen]);
|
||||||
|
|
||||||
// --- Outgoing-channel: listen for accept/reject on our own invite ------
|
// --- Outgoing-channel: listen for accept/reject on our own invite ------
|
||||||
// and incoming invites from peers.
|
// and incoming invites from peers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// preview (vite dev in a browser without Tauri), importing the plugin still
|
||||||
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
|
// 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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
subscribePttSettings,
|
subscribePttSettings,
|
||||||
updatePttSettings,
|
updatePttSettings,
|
||||||
} from '../lib/pttSettings';
|
} from '../lib/pttSettings';
|
||||||
|
import {
|
||||||
|
getVoiceHotkeys,
|
||||||
|
subscribeVoiceHotkeys,
|
||||||
|
updateVoiceHotkey,
|
||||||
|
type VoiceHotkeyKind,
|
||||||
|
type VoiceHotkeys,
|
||||||
|
} from '../lib/voiceHotkeys';
|
||||||
import {
|
import {
|
||||||
AUDIO_QUALITY_ORDER,
|
AUDIO_QUALITY_ORDER,
|
||||||
type AudioQuality,
|
type AudioQuality,
|
||||||
@@ -163,6 +170,12 @@ export function SettingsPage() {
|
|||||||
<div className="mt-3 border-t border-line pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<PttControls />
|
<PttControls />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
|
<VoiceHotkeyControls kind="mute" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
|
<VoiceHotkeyControls kind="deafen" />
|
||||||
|
</div>
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<CallE2EEControls />
|
<CallE2EEControls />
|
||||||
</div>
|
</div>
|
||||||
@@ -263,6 +276,96 @@ function PttControls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [hotkeys, setHotkeys] = useState<VoiceHotkeys>(() => getVoiceHotkeys());
|
||||||
|
const [capturing, setCapturing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => subscribeVoiceHotkeys(setHotkeys), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capturing) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// Modifier-only presses shouldn't bind — wait for a real key to
|
||||||
|
// arrive. Escape aborts the capture.
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setCapturing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.code === 'ControlLeft' ||
|
||||||
|
e.code === 'ControlRight' ||
|
||||||
|
e.code === 'ShiftLeft' ||
|
||||||
|
e.code === 'ShiftRight' ||
|
||||||
|
e.code === 'AltLeft' ||
|
||||||
|
e.code === 'AltRight' ||
|
||||||
|
e.code === 'MetaLeft' ||
|
||||||
|
e.code === 'MetaRight'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
updateVoiceHotkey(kind, {
|
||||||
|
key: e.code,
|
||||||
|
ctrl: e.ctrlKey || e.metaKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
alt: e.altKey,
|
||||||
|
});
|
||||||
|
setCapturing(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||||
|
}, [capturing, kind]);
|
||||||
|
|
||||||
|
const binding = hotkeys[kind];
|
||||||
|
const toggleLabel =
|
||||||
|
kind === 'mute'
|
||||||
|
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
|
||||||
|
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
|
||||||
|
const toggleHint =
|
||||||
|
kind === 'mute'
|
||||||
|
? t('app:settings.hotkey_mute_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||||
|
})
|
||||||
|
: t('app:settings.hotkey_deafen_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label={toggleLabel}
|
||||||
|
hint={toggleHint}
|
||||||
|
checked={binding.enabled}
|
||||||
|
onChange={(v) => updateVoiceHotkey(kind, { enabled: v })}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCapturing((v) => !v)}
|
||||||
|
className={
|
||||||
|
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(capturing
|
||||||
|
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||||
|
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{capturing
|
||||||
|
? t('app:settings.hotkey_press_combo', {
|
||||||
|
defaultValue: 'Kombination drücken…',
|
||||||
|
})
|
||||||
|
: binding.keyLabel}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallE2EEControls() {
|
function CallE2EEControls() {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
||||||
|
|||||||
Reference in New Issue
Block a user