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',