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:
@@ -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 .",
|
||||
"prebuild": "mkdir -p build && cp ../../icon.png build/icon.png",
|
||||
"build": "tsc && electron-builder",
|
||||
"clean": "rm -rf dist dist-electron"
|
||||
"clean": "rm -rf dist dist-electron",
|
||||
"postinstall": "electron-rebuild -f -w uiohook-napi"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.0"
|
||||
"electron-updater": "^6.3.0",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"electron": "^40.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -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,8 +14,10 @@ import {
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector';
|
||||
import { KeybindManager } from './keybindManager';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const keybindManager = new KeybindManager();
|
||||
let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
let pendingDeepLink: string | null = null;
|
||||
@@ -276,6 +278,8 @@ function createWindow(): void {
|
||||
},
|
||||
});
|
||||
|
||||
keybindManager.setWindow(mainWindow);
|
||||
|
||||
if (savedState.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
@@ -545,6 +549,15 @@ function registerIpcHandlers(): void {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Keybinds
|
||||
ipcMain.on('keybinds-sync', (_event, keybinds) => {
|
||||
keybindManager.updateKeybinds(keybinds);
|
||||
});
|
||||
|
||||
ipcMain.handle('check-accessibility', () => {
|
||||
return keybindManager.checkAccessibility();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||
@@ -810,5 +823,6 @@ if (!gotTheLock) {
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
stopActivityDetection();
|
||||
keybindManager.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,4 +76,25 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
return () => { ipcRenderer.removeListener('activity-detected', handler); };
|
||||
},
|
||||
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 { useFederationToasts } from '../../hooks/useFederationToasts';
|
||||
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||
import { useKeybinds } from '../../hooks/useKeybinds';
|
||||
import { useDeepLinkHandler } from '../../platform/deepLink';
|
||||
import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
@@ -123,6 +124,9 @@ export function AppLayout() {
|
||||
// Federation toast notifications for remote instance connection state changes
|
||||
useFederationToasts();
|
||||
|
||||
// Keybinds handler
|
||||
useKeybinds();
|
||||
|
||||
// Deep link handler for Electron (backspace:// protocol)
|
||||
useDeepLinkHandler();
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
||||
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
|
||||
import { DesktopPanel } from './settingsPanels/DesktopPanel';
|
||||
import { InstancePanel } from './settingsPanels/InstancePanel';
|
||||
import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
|
||||
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'desktop' | 'instance';
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance';
|
||||
|
||||
function SidebarSubLinks() {
|
||||
const ctx = useSettingsSectionsContext();
|
||||
@@ -65,7 +66,7 @@ export function UserSettingsModal() {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
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
|
||||
if (requested === 'instance' && !isAdmin) {
|
||||
setTab('account');
|
||||
@@ -125,6 +126,7 @@ export function UserSettingsModal() {
|
||||
<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>
|
||||
<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>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -174,6 +176,7 @@ export function UserSettingsModal() {
|
||||
<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>
|
||||
<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>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -216,6 +219,7 @@ export function UserSettingsModal() {
|
||||
{tab === 'voice' && <VoicePanel />}
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
{tab === 'connections' && <ConnectionsPanel />}
|
||||
{tab === 'keybinds' && <KeybindsPanel />}
|
||||
{tab === 'desktop' && <DesktopPanel />}
|
||||
{tab === 'instance' && isAdmin && <InstancePanel />}
|
||||
</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 { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||
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 { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../../utils/voiceActions';
|
||||
|
||||
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`;
|
||||
@@ -20,9 +17,6 @@ export function VoiceControlBar() {
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
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 toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
@@ -30,7 +24,6 @@ export function VoiceControlBar() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const myUser = useAuthStore((s) => s.user);
|
||||
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 spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
|
||||
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
|
||||
@@ -44,102 +37,33 @@ export function VoiceControlBar() {
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleMute = React.useCallback(async () => {
|
||||
if (isSpaceMuted || isSpaceDeafened) return;
|
||||
const wasDeafened = useVoiceStore.getState().isDeafened;
|
||||
toggleMic();
|
||||
broadcastVoiceStatus();
|
||||
// If unmuting while deafened cleared deafen, broadcast via LiveKit data channel
|
||||
if (wasDeafened && !useVoiceStore.getState().isDeafened) {
|
||||
broadcastDeafenViaLiveKit();
|
||||
}
|
||||
}, [isSpaceMuted, isSpaceDeafened, toggleMic]);
|
||||
const handleMute = React.useCallback(() => {
|
||||
handleMuteAction(isSpaceMuted, isSpaceDeafened);
|
||||
}, [isSpaceMuted, isSpaceDeafened]);
|
||||
|
||||
const handleDeafen = React.useCallback(async () => {
|
||||
if (isSpaceDeafened) return;
|
||||
toggleDeafen();
|
||||
broadcastVoiceStatus();
|
||||
broadcastDeafenViaLiveKit();
|
||||
}, [isSpaceDeafened, toggleDeafen]);
|
||||
const handleDeafen = React.useCallback(() => {
|
||||
handleDeafenAction(isSpaceDeafened);
|
||||
}, [isSpaceDeafened]);
|
||||
|
||||
const handleCamera = async () => {
|
||||
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 handleCamera = () => handleCameraAction();
|
||||
|
||||
const handleScreenShare = async () => {
|
||||
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 handleScreenShare = () => handleScreenShareAction();
|
||||
|
||||
const handleDisconnect = () => {
|
||||
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 handleDisconnect = () => handleDisconnectAction();
|
||||
|
||||
const handleFullscreen = () => {
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
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) {
|
||||
if (e.key === 'Escape' && voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleMute, handleDeafen, voiceFullscreen]);
|
||||
}, [voiceFullscreen]);
|
||||
|
||||
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">
|
||||
|
||||
@@ -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
@@ -52,6 +52,13 @@ interface BackspaceElectronAPI {
|
||||
// Activity detection (game/app process scanning)
|
||||
onActivityDetected: (callback: (activity: unknown) => void) => (() => void);
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -79,6 +79,9 @@ interface VoiceState {
|
||||
setOutputVolume: (volume: number) => void;
|
||||
setInputDevice: (deviceId: string) => void;
|
||||
setOutputDevice: (deviceId: string) => void;
|
||||
pttActive: boolean;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setPttActive: (active: boolean) => void;
|
||||
toggleMic: () => void;
|
||||
toggleCamera: () => void;
|
||||
toggleScreenShare: () => void;
|
||||
@@ -122,6 +125,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
isMuted: false,
|
||||
pttActive: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
@@ -290,6 +294,9 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
|
||||
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
||||
|
||||
setMuted: (muted: boolean) => set({ isMuted: muted }),
|
||||
setPttActive: (active: boolean) => set({ pttActive: active }),
|
||||
|
||||
toggleMic: () => set((state) => {
|
||||
// User intent toggle — effective state (intent || serverEnforcement) is
|
||||
// computed at broadcast/hardware time, so the mic stays off while server-muted
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+17
@@ -13,6 +13,9 @@ importers:
|
||||
electron-updater:
|
||||
specifier: ^6.3.0
|
||||
version: 6.8.3
|
||||
uiohook-napi:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: ^40.0.0
|
||||
@@ -4041,6 +4044,10 @@ packages:
|
||||
node-api-version@0.2.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
|
||||
engines: {node: ^12.13 || ^14.13 || >=16}
|
||||
@@ -4989,6 +4996,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uiohook-napi@1.5.5:
|
||||
resolution: {integrity: sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9586,6 +9597,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-gyp@9.4.1:
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
@@ -10717,6 +10730,10 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uiohook-napi@1.5.5:
|
||||
dependencies:
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
Reference in New Issue
Block a user