From c6d345048a3394c1f43e87cc39cf1f1a1c7dd236 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:35:31 +0100 Subject: [PATCH] feat(keybinds): add useKeybinds hook with Electron IPC bridge and web fallback --- packages/web/src/hooks/useKeybinds.ts | 225 ++++++++++++++++++++++++ packages/web/src/platform/electron.d.ts | 7 + 2 files changed, 232 insertions(+) create mode 100644 packages/web/src/hooks/useKeybinds.ts diff --git a/packages/web/src/hooks/useKeybinds.ts b/packages/web/src/hooks/useKeybinds.ts new file mode 100644 index 00000000..f834802c --- /dev/null +++ b/packages/web/src/hooks/useKeybinds.ts @@ -0,0 +1,225 @@ +import { useEffect, useRef } from 'react'; +import { useKeybindStore, Keybind } from '../stores/keybindStore'; +import { useVoiceStore } from '../stores/voiceStore'; +import { isElectron } from '../platform/platform'; +import { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../utils/voiceActions'; +import { broadcastVoiceStatus } from '../utils/voice'; +import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve space-mute/deafen state for the current voice session */ +function getSpaceEnforcementState(): { isSpaceMuted: boolean; isSpaceDeafened: boolean } { + const vs = useVoiceStore.getState(); + const { currentVoiceChannelId, spaceMutedUserIds, spaceDeafenedUserIds } = vs; + if (!currentVoiceChannelId) return { isSpaceMuted: false, isSpaceDeafened: false }; + + const origin = getChannelOrigin(currentVoiceChannelId); + const myId = getMyUserIdForOrigin(origin); + const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId); + const spaceKey = spaceId && myId ? `${spaceId}:${myId}` : ''; + + return { + isSpaceMuted: spaceMutedUserIds.has(spaceKey), + isSpaceDeafened: spaceDeafenedUserIds.has(spaceKey), + }; +} + +/** Dispatch a keybind action to the appropriate voice handler */ +function dispatchKeybindAction(actionId: string, pressed: boolean): void { + const voice = useVoiceStore.getState(); + if (!voice.currentVoiceChannelId) return; + + const { isSpaceMuted, isSpaceDeafened } = getSpaceEnforcementState(); + + switch (actionId) { + case 'toggleMute': + if (pressed) handleMuteAction(isSpaceMuted, isSpaceDeafened); + break; + case 'toggleDeafen': + if (pressed) handleDeafenAction(isSpaceDeafened); + break; + case 'toggleCamera': + if (pressed) handleCameraAction(); + break; + case 'toggleScreenShare': + if (pressed) handleScreenShareAction(); + break; + case 'disconnect': + if (pressed) handleDisconnectAction(); + break; + case 'pushToTalk': + voice.setMuted(!pressed); // pressed=true → unmute, pressed=false → mute + broadcastVoiceStatus(); + break; + } +} + +// --------------------------------------------------------------------------- +// Modifier key detection (for web fallback input suppression) +// --------------------------------------------------------------------------- + +function isCharacterKey(e: KeyboardEvent): boolean { + return e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey; +} + +function isInputElement(target: EventTarget | null): boolean { + if (!target || !(target instanceof HTMLElement)) return false; + return target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target.isContentEditable; +} + +// --------------------------------------------------------------------------- +// Web fallback: capture-phase listeners +// --------------------------------------------------------------------------- + +type WebCleanup = (() => void) | null; + +function setupWebFallback(keybindsRef: React.MutableRefObject): WebCleanup { + const pressedKeys = new Set(); + const activeActions = new Set(); + + function browserCodeToUiohook(code: string): number { + // Stable numeric ID from KeyboardEvent.code — consistent within web context + // because the recorder captures using the same mapping + return code.charCodeAt(0) * 256 + (code.charCodeAt(1) || 0); + } + + function checkKeybinds(isDown: boolean): void { + for (const kb of keybindsRef.current) { + const keysMatch = kb.keys.length === 0 || kb.keys.every((k) => pressedKeys.has(k)); + // Mouse buttons handled separately in mousedown handler + if (!kb.mouseButton && keysMatch && kb.keys.length > 0) { + if (isDown && !activeActions.has(kb.actionId)) { + activeActions.add(kb.actionId); + dispatchKeybindAction(kb.actionId, true); + } + } + } + // Check for releases + if (!isDown) { + for (const actionId of activeActions) { + const kb = keybindsRef.current.find((k) => k.actionId === actionId); + if (kb && !kb.keys.every((k) => pressedKeys.has(k))) { + activeActions.delete(actionId); + dispatchKeybindAction(actionId, false); + } + } + } + } + + function onKeyDown(e: KeyboardEvent): void { + // Input suppression: single character key + no modifiers + input focused → skip + if (isInputElement(e.target) && isCharacterKey(e)) return; + + const code = browserCodeToUiohook(e.code); + pressedKeys.add(code); + checkKeybinds(true); + } + + function onKeyUp(e: KeyboardEvent): void { + const code = browserCodeToUiohook(e.code); + pressedKeys.delete(code); + checkKeybinds(false); + } + + // Browser button index → uiohook button index + const buttonMap: Record = { 1: 3, 3: 4, 4: 5 }; + + function onMouseDown(e: MouseEvent): void { + const uiButton = buttonMap[e.button]; + if (!uiButton) return; + + for (const kb of keybindsRef.current) { + if (kb.mouseButton === uiButton) { + const keysMatch = kb.keys.length === 0 || kb.keys.every((k) => pressedKeys.has(k)); + if (keysMatch && !activeActions.has(kb.actionId)) { + activeActions.add(kb.actionId); + dispatchKeybindAction(kb.actionId, true); + } + } + } + } + + function onMouseUp(e: MouseEvent): void { + const uiButton = buttonMap[e.button]; + if (!uiButton) return; + + for (const actionId of [...activeActions]) { + const kb = keybindsRef.current.find((k) => k.actionId === actionId); + if (kb && kb.mouseButton === uiButton) { + activeActions.delete(actionId); + dispatchKeybindAction(actionId, false); + } + } + } + + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('mousedown', onMouseDown, true); + window.addEventListener('mouseup', onMouseUp, true); + + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('mousedown', onMouseDown, true); + window.removeEventListener('mouseup', onMouseUp, true); + }; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useKeybinds(): void { + const keybinds = useKeybindStore((s) => s.keybinds); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const keybindsRef = useRef(keybinds); + keybindsRef.current = keybinds; + + // --- PTT activation lifecycle --- + useEffect(() => { + const hasPtt = keybinds.some((kb) => kb.actionId === 'pushToTalk'); + const inVoice = !!currentVoiceChannelId; + const voice = useVoiceStore.getState(); + + if (hasPtt && inVoice) { + voice.setPttActive(true); + voice.setMuted(true); + broadcastVoiceStatus(); + } else if (voice.pttActive) { + voice.setPttActive(false); + } + }, [keybinds, currentVoiceChannelId]); + + // --- Electron: IPC bridge --- + useEffect(() => { + if (!isElectron()) return; + const api = window.backspace; + if (!api?.syncKeybinds || !api?.onKeybindAction) return; + + api.syncKeybinds(keybinds.map((kb) => ({ + actionId: kb.actionId, + keys: kb.keys, + mouseButton: kb.mouseButton, + }))); + + const cleanup = api.onKeybindAction((action) => { + dispatchKeybindAction(action.actionId, action.pressed); + }); + + return cleanup; + }, [keybinds]); + + // --- Web fallback: capture-phase listeners --- + useEffect(() => { + if (isElectron()) return; + if (keybinds.length === 0) return; + + const cleanup = setupWebFallback(keybindsRef); + return cleanup ?? undefined; + }, [keybinds]); +} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index 9788d6cc..09fdd82d 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -52,6 +52,13 @@ interface BackspaceElectronAPI { // Activity detection (game/app process scanning) onActivityDetected: (callback: (activity: unknown) => void) => (() => void); getCurrentActivity: () => Promise; + + // Keybind support + syncKeybinds: (keybinds: Array<{ actionId: string; keys: number[]; mouseButton?: number }>) => void; + onKeybindAction: (callback: (action: { actionId: string; pressed: boolean }) => void) => (() => void); + onAccessibilityStatus: (callback: (status: { trusted: boolean }) => void) => (() => void); + onKeybindHookError: (callback: (error: { message: string }) => void) => (() => void); + checkAccessibility: () => Promise; } interface Window {