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:
@@ -25,6 +25,13 @@ import {
|
||||
subscribePttSettings,
|
||||
updatePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
getVoiceHotkeys,
|
||||
subscribeVoiceHotkeys,
|
||||
updateVoiceHotkey,
|
||||
type VoiceHotkeyKind,
|
||||
type VoiceHotkeys,
|
||||
} from '../lib/voiceHotkeys';
|
||||
import {
|
||||
AUDIO_QUALITY_ORDER,
|
||||
type AudioQuality,
|
||||
@@ -163,6 +170,12 @@ export function SettingsPage() {
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<PttControls />
|
||||
</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">
|
||||
<CallE2EEControls />
|
||||
</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() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
||||
|
||||
Reference in New Issue
Block a user