feat(calls): system-wide PTT/deafen on desktop via the native key poll (cinny-desktop #2)
CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m20s
CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m20s
PTT and deafen were DOM key handlers and only fired while Lotus (or the EC
iframe) had focus — alt-tab into a fullscreen game and the voice controls
stopped working.
- useCallHotkeys: while a call is joined and the new device-local
`globalCallHotkeys` setting is on, register {ptt, deafen} bindings with
the desktop (`set_global_hotkeys`, cleared on leave) and act on its
`lotus-global-hotkey` press/release events. Events are ignored while
`document.hasFocus()` so the DOM handlers keep owning the in-focus case
(editable-field and interactive-element checks, no double toggles). PTT
press engages the mic exactly like the DOM path (pttActive set before
unmute), release restores; the existing blur/focus release covers a hold
that spans a focus change. Same modifier rules as the DOM path
(`shouldActOnGlobalHotkey`, tested).
- Settings → Calls: "Hotkeys Work Outside the Window" toggle, Tauri only.
- settingsSync: `globalCallHotkeys` is device-local (never synced).
- LOTUS_FEATURES: desktop section entry.
Native side lands in cinny-desktop (src-tauri/src/native/hotkeys.rs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1478,6 +1478,10 @@ Rounds out the native app beyond Windows (macOS out of scope):
|
|||||||
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
||||||
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
||||||
|
|
||||||
|
### System-Wide Voice Hotkeys (cinny-desktop #2)
|
||||||
|
|
||||||
|
Push to Talk and Push to Deafen keep working while a game or any other app has focus. The desktop does **not** register a global shortcut (that would swallow the key from every app — a bare `Space` PTT would stop other apps typing spaces); instead, only while a call is joined, a native thread polls `GetAsyncKeyState` for the two configured keys every ~8 ms and emits a `lotus-global-hotkey` DOM event on each press/release transition (`src-tauri/src/native/hotkeys.rs`). `useCallHotkeys` ignores those events while the Lotus window itself has focus (the DOM handlers own that case with their editable-field checks), so nothing double-fires. Windows only — Linux/Wayland has no non-consuming path; `global_hotkeys_supported` reports it and the toggle is hidden outside Tauri. Toggle: **Settings → Calls → Hotkeys Work Outside the Window** (device-local, default on).
|
||||||
|
|
||||||
### Custom Window Chrome (P5-47)
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|||||||
@@ -1644,6 +1644,7 @@ function Calls() {
|
|||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||||||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||||||
@@ -1977,6 +1978,15 @@ function Calls() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{isTauriEnv() && (
|
||||||
|
<SettingTile
|
||||||
|
title="Hotkeys Work Outside the Window"
|
||||||
|
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
|
import { isDeafenKeyDown, isPttKeyDown, shouldActOnGlobalHotkey } from './useCallHotkeys';
|
||||||
|
|
||||||
const key = (
|
const key = (
|
||||||
code: string,
|
code: string,
|
||||||
@@ -33,3 +33,20 @@ test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift)
|
|||||||
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
|
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
|
||||||
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
|
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [cinny-desktop #2] Global (unfocused-window) hotkey events follow the same
|
||||||
|
// modifier rules as the DOM path.
|
||||||
|
test('shouldActOnGlobalHotkey ignores Ctrl/Alt/Meta chords', () => {
|
||||||
|
const ev = (mods: Partial<{ ctrl: boolean; alt: boolean; meta: boolean }>) => ({
|
||||||
|
id: 'ptt' as const,
|
||||||
|
state: 'pressed' as const,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
meta: false,
|
||||||
|
...mods,
|
||||||
|
});
|
||||||
|
assert.equal(shouldActOnGlobalHotkey(ev({})), true);
|
||||||
|
assert.equal(shouldActOnGlobalHotkey(ev({ ctrl: true })), false);
|
||||||
|
assert.equal(shouldActOnGlobalHotkey(ev({ alt: true })), false);
|
||||||
|
assert.equal(shouldActOnGlobalHotkey(ev({ meta: true })), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import { atom, useSetAtom } from 'jotai';
|
|||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { invokeTauri, isTauri, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Payload of the desktop's `lotus-global-hotkey` DOM event (cinny-desktop #2). */
|
||||||
|
type GlobalHotkeyEvent = {
|
||||||
|
id: 'ptt' | 'deafen';
|
||||||
|
state: 'pressed' | 'released';
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a global (unfocused-window) hotkey event should act. Mirrors the DOM
|
||||||
|
* rules: PTT ignores Ctrl/Alt/Meta chords, deafen ignores any modifier. There
|
||||||
|
* is no Shift flag in the payload, so a Shift+deafen chord outside the window
|
||||||
|
* does toggle — acceptable for a key the user chose for exactly that.
|
||||||
|
*/
|
||||||
|
export const shouldActOnGlobalHotkey = (e: GlobalHotkeyEvent): boolean =>
|
||||||
|
!e.ctrl && !e.alt && !e.meta;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
|
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
|
||||||
@@ -108,6 +127,7 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||||||
const { microphone } = useCallControlState(embed?.control);
|
const { microphone } = useCallControlState(embed?.control);
|
||||||
const setPttActive = useSetAtom(pttActiveAtom);
|
const setPttActive = useSetAtom(pttActiveAtom);
|
||||||
|
|
||||||
@@ -191,6 +211,52 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||||
}, [pttMode, pttKey, embed, setPttActive]);
|
}, [pttMode, pttKey, embed, setPttActive]);
|
||||||
|
|
||||||
|
// [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The
|
||||||
|
// desktop polls the configured keys without consuming them and emits one
|
||||||
|
// event per press/release transition. While Lotus itself (or the EC iframe)
|
||||||
|
// has focus the DOM handlers above/below own the key — with their
|
||||||
|
// editable-field and interactive-element checks — so global events are
|
||||||
|
// ignored then and nothing double-fires.
|
||||||
|
const embedRef = useRef(embed);
|
||||||
|
useEffect(() => {
|
||||||
|
embedRef.current = embed;
|
||||||
|
}, [embed]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
|
||||||
|
const bindings = [
|
||||||
|
...(pttMode ? [{ id: 'ptt', code: pttKey }] : []),
|
||||||
|
{ id: 'deafen', code: deafenKey },
|
||||||
|
];
|
||||||
|
invokeTauri('set_global_hotkeys', { bindings });
|
||||||
|
return () => {
|
||||||
|
invokeTauri('set_global_hotkeys', { bindings: [] });
|
||||||
|
};
|
||||||
|
}, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey]);
|
||||||
|
useTauriEvent<GlobalHotkeyEvent>('lotus-global-hotkey', (detail) => {
|
||||||
|
const current = embedRef.current;
|
||||||
|
if (!current || !globalCallHotkeys) return;
|
||||||
|
if (document.hasFocus()) return;
|
||||||
|
if (detail.id === 'ptt') {
|
||||||
|
if (!pttModeRef.current) return;
|
||||||
|
if (detail.state === 'pressed') {
|
||||||
|
if (pttActiveRef.current || !shouldActOnGlobalHotkey(detail)) return;
|
||||||
|
current.control.pttActive = true;
|
||||||
|
if (!microphoneRef.current) current.control.setMicrophone(true);
|
||||||
|
pttActiveRef.current = true;
|
||||||
|
setPttActive(true);
|
||||||
|
} else if (pttActiveRef.current) {
|
||||||
|
current.control.pttActive = false;
|
||||||
|
current.control.setMicrophone(false);
|
||||||
|
pttActiveRef.current = false;
|
||||||
|
setPttActive(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detail.id === 'deafen' && detail.state === 'pressed' && shouldActOnGlobalHotkey(detail)) {
|
||||||
|
current.control.toggleSound();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!embed) return undefined;
|
if (!embed) return undefined;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -271,6 +271,10 @@ export interface Settings {
|
|||||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||||
settingsSync: boolean;
|
settingsSync: boolean;
|
||||||
|
|
||||||
|
// [cinny-desktop #2] Desktop only: keep PTT/deafen working while another app
|
||||||
|
// (a game) has focus, via a non-consuming key poll. Device-local.
|
||||||
|
globalCallHotkeys: boolean;
|
||||||
|
|
||||||
pauseAnimations: boolean;
|
pauseAnimations: boolean;
|
||||||
|
|
||||||
composerToolbarButtons: ComposerToolbarSettings;
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
@@ -384,6 +388,8 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
settingsSync: true,
|
settingsSync: true,
|
||||||
|
|
||||||
|
globalCallHotkeys: true,
|
||||||
|
|
||||||
pauseAnimations: false,
|
pauseAnimations: false,
|
||||||
|
|
||||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type SyncedSettingsContent = {
|
|||||||
// another.
|
// another.
|
||||||
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
||||||
'settingsSync',
|
'settingsSync',
|
||||||
|
'globalCallHotkeys',
|
||||||
'pageZoom',
|
'pageZoom',
|
||||||
'mediaAutoLoad',
|
'mediaAutoLoad',
|
||||||
'pauseAnimations',
|
'pauseAnimations',
|
||||||
|
|||||||
Reference in New Issue
Block a user