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 .",
|
"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"
|
||||||
|
|||||||
@@ -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 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
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
|
||||||
|
|||||||
@@ -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:
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user