feat: add user-configurable global keybinds for voice actions

Adds keyboard/mouse shortcut support for mute, deafen, camera,
screenshare, push-to-talk, and disconnect. Desktop app uses
uiohook-napi for OS-level global shortcuts that work while minimized.
Web fallback uses capture-phase listeners. Settings UI with keybind
recorder, conflict detection, and platform-specific permission handling.
This commit is contained in:
Jannis Braun
2026-03-22 20:51:40 +01:00
15 changed files with 1132 additions and 92 deletions
+5 -2
View File
@@ -14,12 +14,15 @@
"dev": "mkdir -p build && cp ../../icon.png build/icon.png && bash scripts/gen-icns.sh && cp build/icon.icns $(find ../../node_modules -path '*/electron/dist/Electron.app/Contents/Resources/electron.icns' 2>/dev/null | head -1) 2>/dev/null; tsc && electron .", "dev": "mkdir -p build && cp ../../icon.png build/icon.png && bash scripts/gen-icns.sh && cp build/icon.icns $(find ../../node_modules -path '*/electron/dist/Electron.app/Contents/Resources/electron.icns' 2>/dev/null | head -1) 2>/dev/null; tsc && electron .",
"prebuild": "mkdir -p build && cp ../../icon.png build/icon.png", "prebuild": "mkdir -p build && cp ../../icon.png build/icon.png",
"build": "tsc && electron-builder", "build": "tsc && electron-builder",
"clean": "rm -rf dist dist-electron" "clean": "rm -rf dist dist-electron",
"postinstall": "electron-rebuild -f -w uiohook-napi"
}, },
"dependencies": { "dependencies": {
"electron-updater": "^6.3.0" "electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"electron": "^40.0.0", "electron": "^40.0.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"typescript": "^5.7.2" "typescript": "^5.7.2"
+156
View File
@@ -0,0 +1,156 @@
import { uIOhook, UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi';
import { BrowserWindow, systemPreferences } from 'electron';
interface KeybindConfig {
actionId: string;
keys: number[];
mouseButton?: number;
}
export class KeybindManager {
private keybinds: KeybindConfig[] = [];
private pressedKeys = new Set<number>();
private activeActions = new Set<string>();
private window: BrowserWindow | null = null;
private started = false;
constructor() {
this.onKeyDown = this.onKeyDown.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
}
setWindow(win: BrowserWindow): void {
this.window = win;
}
updateKeybinds(keybinds: KeybindConfig[]): void {
this.keybinds = keybinds;
for (const actionId of this.activeActions) {
if (!keybinds.some((kb) => kb.actionId === actionId)) {
this.sendAction(actionId, false);
this.activeActions.delete(actionId);
}
}
if (keybinds.length > 0 && !this.started) {
this.start();
}
if (keybinds.length === 0 && this.started) {
this.stop();
}
}
private start(): void {
if (this.started) return;
if (process.platform === 'darwin') {
const trusted = systemPreferences.isTrustedAccessibilityClient(true);
this.sendAccessibilityStatus(trusted);
if (!trusted) return;
}
uIOhook.on('keydown', this.onKeyDown);
uIOhook.on('keyup', this.onKeyUp);
uIOhook.on('mousedown', this.onMouseDown);
uIOhook.on('mouseup', this.onMouseUp);
try {
uIOhook.start();
this.started = true;
} catch (err) {
console.error('[KeybindManager] Failed to start uiohook:', err);
this.window?.webContents.send('keybind-hook-error', { message: String(err) });
}
}
stop(): void {
if (!this.started) return;
for (const actionId of this.activeActions) {
this.sendAction(actionId, false);
}
this.activeActions.clear();
this.pressedKeys.clear();
uIOhook.removeAllListeners();
try { uIOhook.stop(); } catch { /* ignore */ }
this.started = false;
}
checkAccessibility(): boolean {
if (process.platform !== 'darwin') return true;
return systemPreferences.isTrustedAccessibilityClient(false);
}
private onKeyDown(e: UiohookKeyboardEvent): void {
this.pressedKeys.add(e.keycode);
this.evaluateKeybinds();
}
private onKeyUp(e: UiohookKeyboardEvent): void {
this.pressedKeys.delete(e.keycode);
this.checkReleases();
}
private onMouseDown(e: UiohookMouseEvent): void {
const button = e.button as number;
if (button <= 2) return;
this.evaluateKeybindsWithMouse(button);
}
private onMouseUp(e: UiohookMouseEvent): void {
const button = e.button as number;
if (button <= 2) return;
this.checkMouseReleases(button);
}
private evaluateKeybinds(): void {
for (const kb of this.keybinds) {
if (this.activeActions.has(kb.actionId)) continue;
if (kb.mouseButton) continue;
if (kb.keys.length === 0) continue;
if (kb.keys.every((k) => this.pressedKeys.has(k))) {
this.activeActions.add(kb.actionId);
this.sendAction(kb.actionId, true);
}
}
}
private evaluateKeybindsWithMouse(mouseButton: number): void {
for (const kb of this.keybinds) {
if (this.activeActions.has(kb.actionId)) continue;
if (kb.mouseButton !== mouseButton) continue;
if (kb.keys.every((k) => this.pressedKeys.has(k))) {
this.activeActions.add(kb.actionId);
this.sendAction(kb.actionId, true);
}
}
}
private checkReleases(): void {
for (const actionId of this.activeActions) {
const kb = this.keybinds.find((k) => k.actionId === actionId);
if (!kb) continue;
if (kb.mouseButton) continue;
if (!kb.keys.every((k) => this.pressedKeys.has(k))) {
this.activeActions.delete(actionId);
this.sendAction(actionId, false);
}
}
}
private checkMouseReleases(mouseButton: number): void {
for (const actionId of this.activeActions) {
const kb = this.keybinds.find((k) => k.actionId === actionId);
if (!kb || kb.mouseButton !== mouseButton) continue;
this.activeActions.delete(actionId);
this.sendAction(actionId, false);
}
}
private sendAction(actionId: string, pressed: boolean): void {
if (!this.window || this.window.isDestroyed()) return;
this.window.webContents.send('keybind-action', { actionId, pressed });
}
private sendAccessibilityStatus(trusted: boolean): void {
if (!this.window || this.window.isDestroyed()) return;
this.window.webContents.send('accessibility-status', { trusted });
}
}
+14
View File
@@ -14,8 +14,10 @@ import {
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector'; import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector';
import { KeybindManager } from './keybindManager';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
const keybindManager = new KeybindManager();
let tray: Tray | null = null; let tray: Tray | null = null;
let isQuitting = false; let isQuitting = false;
let pendingDeepLink: string | null = null; let pendingDeepLink: string | null = null;
@@ -276,6 +278,8 @@ function createWindow(): void {
}, },
}); });
keybindManager.setWindow(mainWindow);
if (savedState.isMaximized) { if (savedState.isMaximized) {
mainWindow.maximize(); mainWindow.maximize();
} }
@@ -545,6 +549,15 @@ function registerIpcHandlers(): void {
return updated; return updated;
}); });
// Keybinds
ipcMain.on('keybinds-sync', (_event, keybinds) => {
keybindManager.updateKeybinds(keybinds);
});
ipcMain.handle('check-accessibility', () => {
return keybindManager.checkAccessibility();
});
} }
// ─── Auto-Update ──────────────────────────────────────────────────────────── // ─── Auto-Update ────────────────────────────────────────────────────────────
@@ -810,5 +823,6 @@ if (!gotTheLock) {
app.on('before-quit', () => { app.on('before-quit', () => {
isQuitting = true; isQuitting = true;
stopActivityDetection(); stopActivityDetection();
keybindManager.stop();
}); });
} }
+21
View File
@@ -76,4 +76,25 @@ contextBridge.exposeInMainWorld('backspace', {
return () => { ipcRenderer.removeListener('activity-detected', handler); }; return () => { ipcRenderer.removeListener('activity-detected', handler); };
}, },
getCurrentActivity: () => ipcRenderer.invoke('get-current-activity'), getCurrentActivity: () => ipcRenderer.invoke('get-current-activity'),
// Keybind support
syncKeybinds: (keybinds: Array<{ actionId: string; keys: number[]; mouseButton?: number }>) => {
ipcRenderer.send('keybinds-sync', keybinds);
},
onKeybindAction: (callback: (action: { actionId: string; pressed: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, action: { actionId: string; pressed: boolean }) => callback(action);
ipcRenderer.on('keybind-action', handler);
return () => { ipcRenderer.removeListener('keybind-action', handler); };
},
onAccessibilityStatus: (callback: (status: { trusted: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: { trusted: boolean }) => callback(status);
ipcRenderer.on('accessibility-status', handler);
return () => { ipcRenderer.removeListener('accessibility-status', handler); };
},
onKeybindHookError: (callback: (error: { message: string }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, error: { message: string }) => callback(error);
ipcRenderer.on('keybind-hook-error', handler);
return () => { ipcRenderer.removeListener('keybind-hook-error', handler); };
},
checkAccessibility: () => ipcRenderer.invoke('check-accessibility'),
}); });
@@ -32,6 +32,7 @@ import { useDelayedLoading } from '../../hooks/useDelayedLoading';
import { useWebSocket } from '../../hooks/useWebSocket'; import { useWebSocket } from '../../hooks/useWebSocket';
import { useFederationToasts } from '../../hooks/useFederationToasts'; import { useFederationToasts } from '../../hooks/useFederationToasts';
import { useLiveKit } from '../../hooks/useLiveKit'; import { useLiveKit } from '../../hooks/useLiveKit';
import { useKeybinds } from '../../hooks/useKeybinds';
import { useDeepLinkHandler } from '../../platform/deepLink'; import { useDeepLinkHandler } from '../../platform/deepLink';
import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge'; import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
@@ -123,6 +124,9 @@ export function AppLayout() {
// Federation toast notifications for remote instance connection state changes // Federation toast notifications for remote instance connection state changes
useFederationToasts(); useFederationToasts();
// Keybinds handler
useKeybinds();
// Deep link handler for Electron (backspace:// protocol) // Deep link handler for Electron (backspace:// protocol)
useDeepLinkHandler(); useDeepLinkHandler();
@@ -9,10 +9,11 @@ import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
import { DesktopPanel } from './settingsPanels/DesktopPanel'; import { DesktopPanel } from './settingsPanels/DesktopPanel';
import { InstancePanel } from './settingsPanels/InstancePanel'; import { InstancePanel } from './settingsPanels/InstancePanel';
import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
import { isElectron } from '../../platform/platform'; import { isElectron } from '../../platform/platform';
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext'; import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'desktop' | 'instance'; type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance';
function SidebarSubLinks() { function SidebarSubLinks() {
const ctx = useSettingsSectionsContext(); const ctx = useSettingsSectionsContext();
@@ -65,7 +66,7 @@ export function UserSettingsModal() {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const requested = modalData.tab as SettingsTab | undefined; const requested = modalData.tab as SettingsTab | undefined;
if (requested && ['account', 'voice', 'privacy', 'connections', 'instance'].includes(requested)) { if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) {
// Only allow instance tab for admins // Only allow instance tab for admins
if (requested === 'instance' && !isAdmin) { if (requested === 'instance' && !isAdmin) {
setTab('account'); setTab('account');
@@ -125,6 +126,7 @@ export function UserSettingsModal() {
<div className="border-t border-white/[0.04] my-2 mx-2" /> <div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button> <button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>} {isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && ( {isAdmin && (
@@ -174,6 +176,7 @@ export function UserSettingsModal() {
<div className="border-t border-white/[0.04] my-2 mx-2" /> <div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button> <button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>} {isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && ( {isAdmin && (
@@ -216,6 +219,7 @@ export function UserSettingsModal() {
{tab === 'voice' && <VoicePanel />} {tab === 'voice' && <VoicePanel />}
{tab === 'privacy' && <PrivacyPanel />} {tab === 'privacy' && <PrivacyPanel />}
{tab === 'connections' && <ConnectionsPanel />} {tab === 'connections' && <ConnectionsPanel />}
{tab === 'keybinds' && <KeybindsPanel />}
{tab === 'desktop' && <DesktopPanel />} {tab === 'desktop' && <DesktopPanel />}
{tab === 'instance' && isAdmin && <InstancePanel />} {tab === 'instance' && isAdmin && <InstancePanel />}
</div> </div>
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useKeybindStore, BINDABLE_ACTIONS, Keybind } from '../../../stores/keybindStore';
import { isElectron, isElectronMac } from '../../../platform/platform';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MODIFIER_CODES = new Set([
'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight',
'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight',
]);
const MOUSE_BUTTON_MAP: Record<number, number> = { 1: 3, 3: 4, 4: 5 };
const MOUSE_BUTTON_NAMES: Record<number, string> = { 3: 'Middle Click', 4: 'Mouse 4', 5: 'Mouse 5' };
function keyDisplayName(code: string, key: string): string {
if (code.startsWith('Shift')) return 'Shift';
if (code.startsWith('Control')) return 'Ctrl';
if (code.startsWith('Alt')) return isElectronMac() ? 'Option' : 'Alt';
if (code.startsWith('Meta')) return isElectronMac() ? 'Cmd' : 'Win';
if (code.startsWith('Key')) return code.slice(3).toUpperCase();
if (code.startsWith('Digit')) return code.slice(5);
if (key === ' ') return 'Space';
return key.length === 1 ? key.toUpperCase() : key;
}
function codeToNumeric(code: string): number {
return code.charCodeAt(0) * 256 + (code.charCodeAt(1) || 0);
}
// ---------------------------------------------------------------------------
// Keybind Row
// ---------------------------------------------------------------------------
interface KeybindRowProps {
actionId: string;
label: string;
keybind: Keybind | undefined;
isRecording: boolean;
recordingDisplay: string;
onStartRecording: () => void;
onDelete: () => void;
rowRef: React.RefObject<HTMLDivElement>;
}
function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, onStartRecording, onDelete, rowRef }: KeybindRowProps) {
return (
<div
ref={rowRef}
className={`flex items-center justify-between px-4 py-3 rounded-lg transition-all ${
isRecording
? 'ring-2 ring-accent-mint bg-surface-elevated'
: 'bg-surface-primary hover:bg-surface-elevated'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm text-txt-primary">{label}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
{isRecording ? (
<span className="text-accent-mint animate-pulse">
{recordingDisplay || 'Press a key combo...'}
</span>
) : keybind ? (
keybind.displayLabel
) : (
'Not bound'
)}
</div>
</div>
<div className="flex items-center gap-1.5 ml-3">
{!isRecording && (
<>
<button
onClick={onStartRecording}
className="text-xs px-2.5 py-1 rounded text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] transition-colors"
>
{keybind ? 'Edit' : 'Record'}
</button>
{keybind && (
<button
onClick={onDelete}
className="text-xs px-2.5 py-1 rounded text-txt-tertiary hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
>
Delete
</button>
)}
</>
)}
{isRecording && (
<span className="text-[10px] text-txt-tertiary">ESC to cancel</span>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Conflict Dialog (inline)
// ---------------------------------------------------------------------------
interface ConflictInfo {
newActionId: string;
existingKeybind: Keybind;
pendingKeybind: Keybind;
}
// ---------------------------------------------------------------------------
// KeybindsPanel
// ---------------------------------------------------------------------------
export function KeybindsPanel() {
const { keybinds, setKeybind, removeKeybind, findConflict } = useKeybindStore();
const [recordingActionId, setRecordingActionId] = useState<string | null>(null);
const [recordingDisplay, setRecordingDisplay] = useState('');
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const [accessibilityTrusted, setAccessibilityTrusted] = useState<boolean | null>(null);
const [hookError, setHookError] = useState<string | null>(null);
const pressedCodesRef = useRef(new Map<string, { numeric: number; display: string }>());
const mouseButtonRef = useRef<number | null>(null);
const mouseDisplayRef = useRef<string | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowRefs = useRef<Record<string, React.RefObject<HTMLDivElement>>>({});
for (const action of BINDABLE_ACTIONS) {
if (!rowRefs.current[action.id]) {
rowRefs.current[action.id] = React.createRef();
}
}
// --- Platform checks on mount ---
useEffect(() => {
if (isElectronMac() && window.backspace?.checkAccessibility) {
window.backspace.checkAccessibility().then(setAccessibilityTrusted);
}
if (isElectron() && window.backspace?.onAccessibilityStatus) {
const cleanup = window.backspace.onAccessibilityStatus((status) => {
setAccessibilityTrusted(status.trusted);
});
return cleanup;
}
}, []);
useEffect(() => {
if (isElectron() && window.backspace?.onKeybindHookError) {
const cleanup = window.backspace.onKeybindHookError((error) => {
setHookError(error.message);
});
return cleanup;
}
}, []);
// --- Recording logic ---
const cancelRecording = useCallback(() => {
setRecordingActionId(null);
setRecordingDisplay('');
pressedCodesRef.current.clear();
mouseButtonRef.current = null;
mouseDisplayRef.current = null;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
}, []);
const finalizeRecording = useCallback((actionId: string) => {
const codes = pressedCodesRef.current;
const mouseBtn = mouseButtonRef.current;
const keys = Array.from(codes.values()).map((c) => c.numeric).sort((a, b) => a - b);
const displayParts = Array.from(codes.values()).map((c) => c.display);
if (mouseBtn && MOUSE_BUTTON_NAMES[mouseBtn]) {
displayParts.push(MOUSE_BUTTON_NAMES[mouseBtn]);
}
const displayLabel = displayParts.join(' + ');
const hasNonModifier = Array.from(codes.keys()).some((code) => !MODIFIER_CODES.has(code));
if (!hasNonModifier && !mouseBtn) {
return;
}
const newKeybind: Keybind = { actionId, keys, mouseButton: mouseBtn ?? undefined, displayLabel };
const existing = findConflict(keys, mouseBtn ?? undefined, actionId);
if (existing) {
setConflict({ newActionId: actionId, existingKeybind: existing, pendingKeybind: newKeybind });
cancelRecording();
return;
}
setKeybind(newKeybind);
cancelRecording();
}, [findConflict, setKeybind, cancelRecording]);
// --- Recording event listeners ---
useEffect(() => {
if (!recordingActionId) return;
function onKeyDown(e: KeyboardEvent) {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
cancelRecording();
return;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const display = keyDisplayName(e.code, e.key);
const numeric = codeToNumeric(e.code);
pressedCodesRef.current.set(e.code, { numeric, display });
const parts = Array.from(pressedCodesRef.current.values()).map((c) => c.display);
if (mouseDisplayRef.current) parts.push(mouseDisplayRef.current);
setRecordingDisplay(parts.join(' + '));
}
function onKeyUp(e: KeyboardEvent) {
e.preventDefault();
e.stopPropagation();
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
if (recordingActionId) finalizeRecording(recordingActionId);
}, 300);
}
function onMouseDown(e: MouseEvent) {
const rowRef = rowRefs.current[recordingActionId!];
if (rowRef?.current && !rowRef.current.contains(e.target as Node)) {
cancelRecording();
return;
}
const uiButton = MOUSE_BUTTON_MAP[e.button];
if (!uiButton) return;
e.preventDefault();
e.stopPropagation();
mouseButtonRef.current = uiButton;
mouseDisplayRef.current = MOUSE_BUTTON_NAMES[uiButton] ?? `Mouse ${uiButton}`;
const parts = Array.from(pressedCodesRef.current.values()).map((c) => c.display);
parts.push(mouseDisplayRef.current);
setRecordingDisplay(parts.join(' + '));
finalizeRecording(recordingActionId!);
}
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('mousedown', onMouseDown, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('mousedown', onMouseDown, true);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [recordingActionId, cancelRecording, finalizeRecording]);
// --- Conflict resolution ---
const confirmConflict = () => {
if (!conflict) return;
removeKeybind(conflict.existingKeybind.actionId);
setKeybind(conflict.pendingKeybind);
setConflict(null);
};
const cancelConflict = () => setConflict(null);
const getKeybind = (actionId: string) => keybinds.find((kb) => kb.actionId === actionId);
return (
<div className="space-y-5">
{/* macOS Accessibility Warning */}
{isElectronMac() && accessibilityTrusted === false && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
<div className="text-sm text-amber-200 font-medium">Accessibility Permission Required</div>
<div className="text-xs text-amber-200/70 mt-1">
Backspace needs Accessibility permission for global shortcuts to work outside the app.
</div>
<button
onClick={() => {
window.backspace?.checkAccessibility().then(setAccessibilityTrusted);
}}
className="mt-2 text-xs px-3 py-1.5 rounded bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 transition-colors"
>
Grant Permission
</button>
</div>
)}
{/* Linux hook error warning */}
{isElectron() && hookError && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
<div className="text-sm text-amber-200 font-medium">Global Shortcuts Unavailable</div>
<div className="text-xs text-amber-200/70 mt-1">
Failed to start input listener. On Linux, your user may need to be in the <code className="bg-black/20 px-1 rounded">input</code> group.
</div>
</div>
)}
{/* Web limitation note */}
{!isElectron() && (
<div className="text-xs text-txt-tertiary px-1">
Shortcuts work while this tab is focused. For global shortcuts that work in other apps, use the desktop app.
</div>
)}
{/* Keybind rows */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Shortcuts
</div>
<div className="space-y-1.5">
{BINDABLE_ACTIONS.map((action) => (
<KeybindRow
key={action.id}
actionId={action.id}
label={action.label}
keybind={getKeybind(action.id)}
isRecording={recordingActionId === action.id}
recordingDisplay={recordingDisplay}
onStartRecording={() => {
cancelRecording();
setRecordingActionId(action.id);
}}
onDelete={() => removeKeybind(action.id)}
rowRef={rowRefs.current[action.id]!}
/>
))}
</div>
</div>
{/* Conflict dialog */}
{conflict && (
<div className="rounded-lg bg-surface-elevated border border-white/[0.06] p-3.5">
<div className="text-sm text-txt-primary">
<span className="font-medium">{conflict.pendingKeybind.displayLabel}</span> is already bound to{' '}
<span className="font-medium">
{BINDABLE_ACTIONS.find((a) => a.id === conflict.existingKeybind.actionId)?.label}
</span>
. Overwrite?
</div>
<div className="flex gap-2 mt-2.5">
<button
onClick={confirmConflict}
className="text-xs px-3 py-1.5 rounded bg-accent-mint/20 text-accent-mint hover:bg-accent-mint/30 transition-colors"
>
Overwrite
</button>
<button
onClick={cancelConflict}
className="text-xs px-3 py-1.5 rounded bg-white/[0.06] text-txt-secondary hover:bg-white/[0.1] transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
@@ -2,13 +2,10 @@ import React, { useEffect, useState, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../../utils/voiceActions';
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`; const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`;
@@ -20,9 +17,6 @@ export function VoiceControlBar() {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
@@ -30,7 +24,6 @@ export function VoiceControlBar() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const myUser = useAuthStore((s) => s.user); const myUser = useAuthStore((s) => s.user);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id); const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id);
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
@@ -44,102 +37,33 @@ export function VoiceControlBar() {
const [qualityOpen, setQualityOpen] = useState(false); const [qualityOpen, setQualityOpen] = useState(false);
const qualityBtnRef = useRef<HTMLButtonElement>(null); const qualityBtnRef = useRef<HTMLButtonElement>(null);
const handleMute = React.useCallback(async () => { const handleMute = React.useCallback(() => {
if (isSpaceMuted || isSpaceDeafened) return; handleMuteAction(isSpaceMuted, isSpaceDeafened);
const wasDeafened = useVoiceStore.getState().isDeafened; }, [isSpaceMuted, isSpaceDeafened]);
toggleMic();
broadcastVoiceStatus();
// If unmuting while deafened cleared deafen, broadcast via LiveKit data channel
if (wasDeafened && !useVoiceStore.getState().isDeafened) {
broadcastDeafenViaLiveKit();
}
}, [isSpaceMuted, isSpaceDeafened, toggleMic]);
const handleDeafen = React.useCallback(async () => { const handleDeafen = React.useCallback(() => {
if (isSpaceDeafened) return; handleDeafenAction(isSpaceDeafened);
toggleDeafen(); }, [isSpaceDeafened]);
broadcastVoiceStatus();
broadcastDeafenViaLiveKit();
}, [isSpaceDeafened, toggleDeafen]);
const handleCamera = async () => { const handleCamera = () => handleCameraAction();
const room = getActiveRoom();
if (!room) return;
try {
const willEnable = !isCameraOn;
if (willEnable) {
await room.localParticipant.setCameraEnabled(true,
{ resolution: CAMERA_PRESET.resolution },
{
videoCodec: CAMERA_PRESET.codec,
videoEncoding: CAMERA_PRESET.encoding,
simulcast: true,
}
);
} else {
await room.localParticipant.setCameraEnabled(false);
}
toggleCamera();
broadcastVoiceStatus();
} catch (err) {
console.error('[VoiceControlBar] Failed to toggle camera:', err);
}
};
const handleScreenShare = async () => { const handleScreenShare = () => handleScreenShareAction();
const room = getActiveRoom();
if (!room) return;
try {
if (!isScreenSharing) {
const started = await startScreenShare(room);
if (started) broadcastVoiceStatus();
} else {
await stopScreenShare(room);
broadcastVoiceStatus();
}
} catch (err) {
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
}
};
const handleDisconnect = () => { const handleDisconnect = () => handleDisconnectAction();
const { activeDmCall } = useVoiceStore.getState();
if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
useVoiceStore.getState().setActiveDmCall(null);
} else {
wsSend({ type: 'voice_leave' }, voiceOrigin);
useVoiceStore.getState().leaveVoice();
}
if (voiceFullscreen) {
useUIStore.getState().setVoiceFullscreen(false);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
};
const handleFullscreen = () => { const handleFullscreen = () => {
toggleVoiceFullscreen(); toggleVoiceFullscreen();
}; };
// Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === 'Escape' && voiceFullscreen) {
if (e.key === 'm' || e.key === 'M') {
e.preventDefault();
handleMute();
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
handleDeafen();
} else if (e.key === 'Escape' && voiceFullscreen) {
useUIStore.getState().setVoiceFullscreen(false); useUIStore.getState().setVoiceFullscreen(false);
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleMute, handleDeafen, voiceFullscreen]); }, [voiceFullscreen]);
return ( return (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 opacity-0 translate-y-4 group-hover/voice:opacity-100 group-hover/voice:translate-y-0 transition-all duration-300 ease-out"> <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 opacity-0 translate-y-4 group-hover/voice:opacity-100 group-hover/voice:translate-y-0 transition-all duration-300 ease-out">
+225
View File
@@ -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<Keybind[]>): WebCleanup {
const pressedKeys = new Set<number>();
const activeActions = new Set<string>();
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<number, number> = { 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]);
}
+7
View File
@@ -52,6 +52,13 @@ interface BackspaceElectronAPI {
// Activity detection (game/app process scanning) // Activity detection (game/app process scanning)
onActivityDetected: (callback: (activity: unknown) => void) => (() => void); onActivityDetected: (callback: (activity: unknown) => void) => (() => void);
getCurrentActivity: () => Promise<unknown>; getCurrentActivity: () => Promise<unknown>;
// 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<boolean>;
} }
interface Window { interface Window {
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useKeybindStore } from './keybindStore';
beforeEach(() => {
useKeybindStore.setState({ keybinds: [] });
});
describe('keybindStore', () => {
it('starts with no keybinds', () => {
expect(useKeybindStore.getState().keybinds).toEqual([]);
});
it('adds a keybind', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [42, 50],
displayLabel: 'Shift + M',
});
const kb = useKeybindStore.getState().keybinds;
expect(kb).toHaveLength(1);
expect(kb[0].actionId).toBe('toggleMute');
expect(kb[0].keys).toEqual([42, 50]); // sorted
});
it('sorts keys on save', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [50, 42],
displayLabel: 'Shift + M',
});
expect(useKeybindStore.getState().keybinds[0].keys).toEqual([42, 50]);
});
it('replaces existing keybind for same action', () => {
const store = useKeybindStore.getState();
store.setKeybind({ actionId: 'toggleMute', keys: [42], displayLabel: 'Shift' });
store.setKeybind({ actionId: 'toggleMute', keys: [50], displayLabel: 'M' });
expect(useKeybindStore.getState().keybinds).toHaveLength(1);
expect(useKeybindStore.getState().keybinds[0].displayLabel).toBe('M');
});
it('removes a keybind', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [42],
displayLabel: 'Shift',
});
useKeybindStore.getState().removeKeybind('toggleMute');
expect(useKeybindStore.getState().keybinds).toEqual([]);
});
it('detects conflicts', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [42, 50],
displayLabel: 'Shift + M',
});
const conflict = useKeybindStore.getState().findConflict([42, 50]);
expect(conflict).not.toBeNull();
expect(conflict!.actionId).toBe('toggleMute');
});
it('excludes specified action from conflict check', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [42, 50],
displayLabel: 'Shift + M',
});
const conflict = useKeybindStore.getState().findConflict([42, 50], undefined, 'toggleMute');
expect(conflict).toBeNull();
});
it('detects mouse button conflicts', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [],
mouseButton: 4,
displayLabel: 'Mouse 4',
});
const conflict = useKeybindStore.getState().findConflict([], 4);
expect(conflict).not.toBeNull();
});
it('rejects blacklisted mouse buttons', () => {
useKeybindStore.getState().setKeybind({
actionId: 'toggleMute',
keys: [],
mouseButton: 1, // left click — blacklisted
displayLabel: 'Mouse 1',
});
expect(useKeybindStore.getState().keybinds).toEqual([]);
});
it('supports modifier + mouse button combos', () => {
useKeybindStore.getState().setKeybind({
actionId: 'pushToTalk',
keys: [42],
mouseButton: 4,
displayLabel: 'Shift + Mouse 4',
});
const kb = useKeybindStore.getState().keybinds;
expect(kb).toHaveLength(1);
expect(kb[0].keys).toEqual([42]);
expect(kb[0].mouseButton).toBe(4);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Keybind {
actionId: string;
keys: number[]; // uIOhook keycodes, sorted ascending
mouseButton?: number; // uIOhook mouse button (3=middle, 4=back, 5=forward)
displayLabel: string; // human-readable, captured at record time
}
export const BINDABLE_ACTIONS = [
{ id: 'toggleMute', label: 'Toggle Mute', type: 'toggle' as const },
{ id: 'toggleDeafen', label: 'Toggle Deafen', type: 'toggle' as const },
{ id: 'pushToTalk', label: 'Push to Talk', type: 'hold' as const },
{ id: 'toggleCamera', label: 'Toggle Camera', type: 'toggle' as const },
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', type: 'toggle' as const },
{ id: 'disconnect', label: 'Disconnect', type: 'toggle' as const },
] as const;
/** Mouse buttons that must not be bound (would break OS interaction) */
const BLACKLISTED_MOUSE_BUTTONS = new Set([1, 2]); // left, right
interface KeybindState {
keybinds: Keybind[];
setKeybind: (keybind: Keybind) => void;
removeKeybind: (actionId: string) => void;
findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => Keybind | null;
}
function keybindsEqual(a: Keybind, b: { keys: number[]; mouseButton?: number }): boolean {
if (a.keys.length !== b.keys.length) return false;
if (a.mouseButton !== b.mouseButton) return false;
return a.keys.every((k, i) => k === b.keys[i]);
}
export const useKeybindStore = create<KeybindState>()(
persist(
(set, get) => ({
keybinds: [],
setKeybind: (keybind: Keybind) => {
if (keybind.mouseButton && BLACKLISTED_MOUSE_BUTTONS.has(keybind.mouseButton)) return;
const sorted = { ...keybind, keys: [...keybind.keys].sort((a, b) => a - b) };
set((state) => ({
keybinds: [
...state.keybinds.filter((kb) => kb.actionId !== sorted.actionId),
sorted,
],
}));
},
removeKeybind: (actionId: string) => {
set((state) => ({
keybinds: state.keybinds.filter((kb) => kb.actionId !== actionId),
}));
},
findConflict: (keys: number[], mouseButton?: number, excludeActionId?: string) => {
const sorted = [...keys].sort((a, b) => a - b);
return get().keybinds.find(
(kb) => kb.actionId !== excludeActionId && keybindsEqual(kb, { keys: sorted, mouseButton })
) ?? null;
},
}),
{
name: 'backspace-keybinds',
version: 1,
}
)
);
+7
View File
@@ -79,6 +79,9 @@ interface VoiceState {
setOutputVolume: (volume: number) => void; setOutputVolume: (volume: number) => void;
setInputDevice: (deviceId: string) => void; setInputDevice: (deviceId: string) => void;
setOutputDevice: (deviceId: string) => void; setOutputDevice: (deviceId: string) => void;
pttActive: boolean;
setMuted: (muted: boolean) => void;
setPttActive: (active: boolean) => void;
toggleMic: () => void; toggleMic: () => void;
toggleCamera: () => void; toggleCamera: () => void;
toggleScreenShare: () => void; toggleScreenShare: () => void;
@@ -122,6 +125,7 @@ export const useVoiceStore = create<VoiceState>()(
voiceUsers: new Map(), voiceUsers: new Map(),
currentVoiceChannelId: null, currentVoiceChannelId: null,
isMuted: false, isMuted: false,
pttActive: false,
isDeafened: false, isDeafened: false,
isCameraOn: false, isCameraOn: false,
isScreenSharing: false, isScreenSharing: false,
@@ -290,6 +294,9 @@ export const useVoiceStore = create<VoiceState>()(
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
setMuted: (muted: boolean) => set({ isMuted: muted }),
setPttActive: (active: boolean) => set({ pttActive: active }),
toggleMic: () => set((state) => { toggleMic: () => set((state) => {
// User intent toggle — effective state (intent || serverEnforcement) is // User intent toggle — effective state (intent || serverEnforcement) is
// computed at broadcast/hardware time, so the mic stays off while server-muted // computed at broadcast/hardware time, so the mic stays off while server-muted
+110
View File
@@ -0,0 +1,110 @@
import { useVoiceStore } from '../stores/voiceStore';
import { useUIStore } from '../stores/uiStore';
import { getActiveRoom } from '../hooks/useLiveKit';
import { wsSend } from '../hooks/useWebSocket';
import { getChannelOrigin } from '../stores/spaceStore';
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from './voice';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from './screenShare';
/**
* Toggle mute. Respects space-mute/deafen guards.
* Extracted from VoiceControlBar so keybinds and buttons share the same logic.
*/
export function handleMuteAction(isSpaceMuted: boolean, isSpaceDeafened: boolean): void {
if (isSpaceMuted || isSpaceDeafened) return;
const wasDeafened = useVoiceStore.getState().isDeafened;
useVoiceStore.getState().toggleMic();
broadcastVoiceStatus();
if (wasDeafened && !useVoiceStore.getState().isDeafened) {
broadcastDeafenViaLiveKit();
}
}
/**
* Toggle deafen. Respects space-deafen guard.
*/
export function handleDeafenAction(isSpaceDeafened: boolean): void {
if (isSpaceDeafened) return;
useVoiceStore.getState().toggleDeafen();
broadcastVoiceStatus();
broadcastDeafenViaLiveKit();
}
/**
* Toggle camera. Requires LiveKit room.
*/
export async function handleCameraAction(): Promise<void> {
const room = getActiveRoom();
if (!room) return;
const isCameraOn = useVoiceStore.getState().isCameraOn;
try {
const willEnable = !isCameraOn;
if (willEnable) {
await room.localParticipant.setCameraEnabled(true,
{ resolution: CAMERA_PRESET.resolution },
{
videoCodec: CAMERA_PRESET.codec,
videoEncoding: CAMERA_PRESET.encoding,
simulcast: true,
}
);
} else {
await room.localParticipant.setCameraEnabled(false);
}
useVoiceStore.getState().toggleCamera();
broadcastVoiceStatus();
} catch (err) {
console.error('[voiceActions] Failed to toggle camera:', err);
}
}
/**
* Toggle screen share. Requires LiveKit room.
* Note: startScreenShare/stopScreenShare manage voiceStore.isScreenSharing internally.
* Do NOT call toggleScreenShare() here — it would double-flip the state.
*/
export async function handleScreenShareAction(): Promise<void> {
const room = getActiveRoom();
if (!room) return;
const isScreenSharing = useVoiceStore.getState().isScreenSharing;
try {
if (!isScreenSharing) {
const started = await startScreenShare(room);
if (started) broadcastVoiceStatus();
} else {
await stopScreenShare(room);
broadcastVoiceStatus();
}
} catch (err) {
console.error('[voiceActions] Failed to toggle screen share:', err);
}
}
/**
* Disconnect from voice. Handles DM call teardown and fullscreen exit.
*/
export function handleDisconnectAction(): void {
const voice = useVoiceStore.getState();
const { activeDmCall, currentVoiceChannelId } = voice;
if (activeDmCall) {
wsSend(
{ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId },
getChannelOrigin(activeDmCall.dmChannelId)
);
voice.setActiveDmCall(null);
} else if (currentVoiceChannelId) {
const origin = getChannelOrigin(currentVoiceChannelId);
wsSend({ type: 'voice_leave' }, origin);
voice.leaveVoice();
}
// Exit fullscreen if active
const voiceFullscreen = useUIStore.getState().voiceFullscreen;
if (voiceFullscreen) {
useUIStore.getState().setVoiceFullscreen(false);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
}
+17
View File
@@ -13,6 +13,9 @@ importers:
electron-updater: electron-updater:
specifier: ^6.3.0 specifier: ^6.3.0
version: 6.8.3 version: 6.8.3
uiohook-napi:
specifier: ^1.5.5
version: 1.5.5
devDependencies: devDependencies:
electron: electron:
specifier: ^40.0.0 specifier: ^40.0.0
@@ -4041,6 +4044,10 @@ packages:
node-api-version@0.2.1: node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-gyp@9.4.1: node-gyp@9.4.1:
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
engines: {node: ^12.13 || ^14.13 || >=16} engines: {node: ^12.13 || ^14.13 || >=16}
@@ -4989,6 +4996,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uiohook-napi@1.5.5:
resolution: {integrity: sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==}
engines: {node: '>= 16'}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -9586,6 +9597,8 @@ snapshots:
dependencies: dependencies:
semver: 7.7.4 semver: 7.7.4
node-gyp-build@4.8.4: {}
node-gyp@9.4.1: node-gyp@9.4.1:
dependencies: dependencies:
env-paths: 2.2.1 env-paths: 2.2.1
@@ -10717,6 +10730,10 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
uiohook-napi@1.5.5:
dependencies:
node-gyp-build: 4.8.4
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4