diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md
index fb79a4615..b1ada29f3 100644
--- a/LOTUS_FEATURES.md
+++ b/LOTUS_FEATURES.md
@@ -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).
- **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)
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`.
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 47d16cd8a..d92015006 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -1644,6 +1644,7 @@ function Calls() {
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
+ const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
@@ -1977,6 +1978,15 @@ function Calls() {
}
/>
+ {isTauriEnv() && (
+
+ }
+ />
+ )}
{
+ 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);
+});
diff --git a/src/app/hooks/useCallHotkeys.ts b/src/app/hooks/useCallHotkeys.ts
index 318bfc83d..3900822ae 100644
--- a/src/app/hooks/useCallHotkeys.ts
+++ b/src/app/hooks/useCallHotkeys.ts
@@ -3,6 +3,25 @@ import { atom, useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/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
@@ -108,6 +127,7 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
+ const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
const { microphone } = useCallControlState(embed?.control);
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
}, [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('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(() => {
if (!embed) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 719af13ac..12beb2997 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -271,6 +271,10 @@ export interface Settings {
// so other devices pick them up. Device-local itself (utils/settingsSync).
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;
composerToolbarButtons: ComposerToolbarSettings;
@@ -384,6 +388,8 @@ const defaultSettings: Settings = {
settingsSync: true,
+ globalCallHotkeys: true,
+
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts
index e653633e7..0a49d338b 100644
--- a/src/app/utils/settingsSync.ts
+++ b/src/app/utils/settingsSync.ts
@@ -27,6 +27,7 @@ export type SyncedSettingsContent = {
// another.
export const DEVICE_LOCAL_KEYS: ReadonlySet = new Set([
'settingsSync',
+ 'globalCallHotkeys',
'pageZoom',
'mediaAutoLoad',
'pauseAnimations',