feat: add PTT, keyboard shortcut engine, persistent auxiliary settings

This commit is contained in:
rektdeckard 2025-05-22 18:44:13 -06:00
parent e418eeaac4
commit 2ccb2e53b9
9 changed files with 469 additions and 28 deletions

View File

@ -2,7 +2,7 @@ import '../styles/globals.css';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import type { Metadata, Viewport } from 'next';
import { Toaster } from 'react-hot-toast';
import { Providers } from '@/lib/Providers';
export const metadata: Metadata = {
title: {
@ -52,8 +52,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<body data-lk-theme="default">
<Toaster />
{children}
<Providers>{children}</Providers>
</body>
</html>
);

View File

@ -1,31 +1,109 @@
'use client';
import React from 'react';
import { Track } from 'livekit-client';
import { useTrackToggle } from '@livekit/components-react';
import { useSettingsState } from './SettingsContext';
import { KeyCommand } from './types';
export function KeyboardShortcuts() {
const { toggle: toggleMic } = useTrackToggle({ source: Track.Source.Microphone });
const { state } = useSettingsState() ?? {};
const { toggle: toggleMic, enabled: micEnabled } = useTrackToggle({
source: Track.Source.Microphone,
});
const { toggle: toggleCamera } = useTrackToggle({ source: Track.Source.Camera });
const [pttHeld, setPttHeld] = React.useState(false);
React.useEffect(() => {
function handleShortcut(event: KeyboardEvent) {
// Toggle microphone: Cmd/Ctrl-Shift-A
if (toggleMic && event.key === 'A' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
toggleMic();
}
const handlers = Object.entries(state.keybindings)
.flatMap(([command, bind]) => {
switch (command) {
case KeyCommand.PTT:
if (!state.enablePTT || !Array.isArray(bind)) return [];
// Toggle camera: Cmd/Ctrl-Shift-V
if (event.key === 'V' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
toggleCamera();
}
}
const [enable, disable] = bind;
const t = getEventTarget(enable.target);
if (!t) return null;
window.addEventListener('keydown', handleShortcut);
return () => window.removeEventListener('keydown', handleShortcut);
}, [toggleMic, toggleCamera]);
const on = (event: KeyboardEvent) => {
if (enable.discriminator(event)) {
event.preventDefault();
if (!micEnabled) {
setPttHeld(true);
toggleMic?.(true);
}
}
};
const off = (event: KeyboardEvent) => {
if (disable.discriminator(event)) {
event.preventDefault();
if (pttHeld && micEnabled) {
setPttHeld(false);
toggleMic?.(false);
}
}
};
t.addEventListener(enable.eventName, on as any);
t.addEventListener(disable.eventName, off as any);
return [
{ eventName: enable.eventName, target: t, handler: on },
{ eventName: disable.eventName, target: t, handler: off },
];
case KeyCommand.ToggleMic:
if (!Array.isArray(bind)) {
const t = getEventTarget(bind.target);
if (!t) return null;
const handler = (event: KeyboardEvent) => {
if (bind.discriminator(event)) {
event.preventDefault();
toggleMic?.();
}
};
t.addEventListener(bind.eventName, handler as any);
return { eventName: bind.eventName, target: t, handler };
}
case KeyCommand.ToggleCamera:
if (!Array.isArray(bind)) {
const t = getEventTarget(bind.target);
if (!t) return null;
const handler = (event: KeyboardEvent) => {
if (bind.discriminator(event)) {
event.preventDefault();
toggleCamera?.();
}
};
t.addEventListener(bind.eventName, handler as any);
return { eventName: bind.eventName, target: t, handler };
}
default:
return [];
}
})
.filter(Boolean) as Array<{
target: EventTarget;
eventName: string;
handler: (event: KeyboardEvent) => void;
}>;
return () => {
handlers.forEach(({ target, eventName, handler }) => {
target.removeEventListener(eventName, handler as any);
});
};
}, [state, pttHeld, micEnabled, toggleMic]);
return null;
}
function getEventTarget(
target: Window | Document | HTMLElement | string = window,
): EventTarget | null {
const targetElement = typeof target === 'string' ? document.querySelector(target) : target;
if (!targetElement) {
console.warn(`Target element not found for ${target}`);
return null;
}
return targetElement;
}

13
lib/Providers.tsx Normal file
View File

@ -0,0 +1,13 @@
'use client';
import { Toaster } from 'react-hot-toast';
import { SettingsStateProvider } from './SettingsContext';
export function Providers({ children }: React.PropsWithChildren) {
return (
<SettingsStateProvider>
<Toaster />
{children}
</SettingsStateProvider>
);
}

97
lib/SettingsContext.tsx Normal file
View File

@ -0,0 +1,97 @@
'use client';
import React, { createContext, SetStateAction, useCallback, useContext, useMemo } from 'react';
import type {
SettingsState,
SettingsStateContextType,
SerializedSettingsState,
KeyBindings,
KeyCommand,
} from './types';
import { defaultKeyBindings, commonKeyBindings } from './keybindings';
import { usePersistToLocalStorage } from './persistence';
const AUXILIARY_USER_CHOICES_KEY = `lk-auxiliary-user-choices`;
const initialState: SettingsState = {
keybindings: defaultKeyBindings,
enablePTT: false,
};
function serializeSettingsState(state: SettingsState): SerializedSettingsState {
return {
...state,
keybindings: Object.entries(state.keybindings).reduce(
(acc, [key, value]) => {
const commonName = Object.entries(commonKeyBindings).find(([_, v]) => v === value)?.[0];
if (commonName) {
acc[key] = commonName;
}
return acc;
},
{} as Record<string, string>,
),
};
}
function deserializeSettingsState(state: SerializedSettingsState): SettingsState {
return {
...state,
keybindings: {
...defaultKeyBindings,
...Object.entries(state.keybindings).reduce((acc, [key, commonName]) => {
const commonBinding = commonKeyBindings[commonName as keyof typeof commonKeyBindings];
if (commonBinding) {
acc[key as keyof typeof defaultKeyBindings] = commonBinding;
}
return acc;
}, {} as KeyBindings),
},
};
}
const SettingsStateContext = createContext<SettingsStateContextType>({
state: initialState,
set: () => {},
});
const SettingsStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, set] = usePersistToLocalStorage<SerializedSettingsState>(
AUXILIARY_USER_CHOICES_KEY,
serializeSettingsState(initialState),
);
const deserializedState = useMemo(() => deserializeSettingsState(state), [state]);
console.info({ deserializedState });
const setSettingsState = useCallback(
(dispatch: SetStateAction<SettingsState>) => {
if (typeof dispatch === 'function') {
set((prev) => {
const next = serializeSettingsState(dispatch(deserializeSettingsState(prev)));
return next;
});
} else {
set(serializeSettingsState(dispatch));
}
},
[set],
);
return (
<SettingsStateContext.Provider value={{ state: deserializedState, set: setSettingsState }}>
{children}
</SettingsStateContext.Provider>
);
};
const useSettingsState = () => {
const ctx = useContext(SettingsStateContext);
if (ctx === null) {
throw new Error('useSettingsState must be used within SettingsStateProvider');
}
return ctx!;
};
export { useSettingsState, SettingsStateProvider, SettingsStateContext };

View File

@ -1,16 +1,18 @@
'use client';
import * as React from 'react';
import { Track } from 'livekit-client';
import {
useMaybeLayoutContext,
MediaDeviceMenu,
TrackToggle,
useRoomContext,
useIsRecording,
} from '@livekit/components-react';
import styles from '../styles/SettingsMenu.module.css';
import { CameraSettings } from './CameraSettings';
import { MicrophoneSettings } from './MicrophoneSettings';
import { useSettingsState } from './SettingsContext';
import { KeyBinding, KeyCommand } from './types';
import { keybindingOptions } from './keybindings';
/**
* @alpha
*/
@ -20,6 +22,7 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
* @alpha
*/
export function SettingsMenu(props: SettingsMenuProps) {
const { state, set: setSettingsState } = useSettingsState();
const layoutContext = useMaybeLayoutContext();
const room = useRoomContext();
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
@ -28,7 +31,11 @@ export function SettingsMenu(props: SettingsMenuProps) {
return {
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
};
keyboard: {
label: 'Keybindings',
keybindings: keybindingOptions,
},
} as const;
}, []);
const tabs = React.useMemo(
@ -73,6 +80,16 @@ export function SettingsMenu(props: SettingsMenuProps) {
}
};
const setKeyBinding = (key: KeyCommand, binds: KeyBinding | [KeyBinding, KeyBinding]) => {
setSettingsState((prev) => ({
...prev,
keybindings: {
...prev.keybindings,
[key]: binds,
},
}));
};
return (
<div className="settings-menu" style={{ width: '100%', position: 'relative' }} {...props}>
<div className={styles.tabs}>
@ -85,10 +102,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
onClick={() => setActiveTab(tab)}
aria-pressed={tab === activeTab}
>
{
// @ts-ignore
settings[tab].label
}
{settings[tab].label}
</button>
),
)}
@ -140,6 +154,36 @@ export function SettingsMenu(props: SettingsMenuProps) {
</section>
</>
)}
{activeTab === 'keyboard' && (
<>
<h3>PTT</h3>
<section>
<button
className="lk-button"
onClick={() => {
setSettingsState((prev) => ({ ...prev, enablePTT: !prev.enablePTT }));
}}
>
{`${state.enablePTT ? 'Disable' : 'Enable'} PTT`}
</button>
</section>
<h4>PTT trigger</h4>
<section>
{settings.keyboard.keybindings[KeyCommand.PTT]?.map(({ label, binds }) => (
<div key={label}>
<input
type="radio"
name="ptt"
id={label}
defaultChecked={state.keybindings[KeyCommand.PTT] === binds}
onChange={() => setKeyBinding(KeyCommand.PTT, binds)}
/>
<label htmlFor={label}>{label}</label>
</div>
))}
</section>
</>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<button

93
lib/keybindings.ts Normal file
View File

@ -0,0 +1,93 @@
import { type KeyBinding, KeyBindings, KeyCommand } from './types';
export function isControlElement(event: Event) {
return (
event.target instanceof HTMLButtonElement ||
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLLabelElement ||
event.target instanceof HTMLSelectElement ||
event.target instanceof HTMLOptionElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLElement && event.target.isContentEditable)
);
}
export function isMouseButton(value: number, event: Event) {
return event instanceof MouseEvent && event.button === value;
}
export const commonKeyBindings: Record<
string,
KeyBinding | [enable: KeyBinding, disable: KeyBinding]
> = {
spacebar: [
{
eventName: 'keydown',
discriminator: (event) => {
return event.code === 'Space' && !isControlElement(event);
},
},
{
eventName: 'keyup',
discriminator: (event) => event.code === 'Space',
},
],
leftMouse: [
{
eventName: 'mousedown',
discriminator: (event) => {
return isMouseButton(0, event) && !isControlElement(event);
},
},
{
eventName: 'mouseup',
discriminator: (event) => isMouseButton(0, event),
},
],
middleMouse: [
{
eventName: 'mousedown',
discriminator: (event) => isMouseButton(1, event),
},
{
eventName: 'mouseup',
discriminator: (event) => isMouseButton(1, event),
},
],
metaShiftA: {
eventName: 'keydown',
discriminator: (event) => event.key === 'A' && (event.ctrlKey || event.metaKey),
},
metaShiftV: {
eventName: 'keydown',
discriminator: (event) => event.key === 'V' && (event.ctrlKey || event.metaKey),
},
} as const;
export const defaultKeyBindings: KeyBindings = {
[KeyCommand.PTT]: commonKeyBindings.spacebar,
[KeyCommand.ToggleMic]: commonKeyBindings.metaShiftA,
[KeyCommand.ToggleCamera]: commonKeyBindings.metaShiftV,
};
export const keybindingOptions: Partial<
Record<
KeyCommand,
{ label: string; binds: KeyBinding | [enable: KeyBinding, disable: KeyBinding] }[]
>
> = {
[KeyCommand.PTT]: [
{
label: 'Spacebar',
binds: commonKeyBindings.spacebar,
},
{
label: 'Left Mouse Button',
binds: commonKeyBindings.leftMouse,
},
{
label: 'Middle Mouse Button',
binds: commonKeyBindings.middleMouse,
},
],
};

79
lib/persistence.ts Normal file
View File

@ -0,0 +1,79 @@
'use client';
import { Dispatch, SetStateAction, useState } from 'react';
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function saveToLocalStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof localStorage === 'undefined') {
console.error('Local storage is not available.');
return;
}
try {
if (value) {
const nonEmptySettings = Object.fromEntries(
Object.entries(value).filter(([, value]) => value !== ''),
);
localStorage.setItem(key, JSON.stringify(nonEmptySettings));
}
} catch (error) {
console.error(`Error setting item to local storage: ${error}`);
}
}
function loadFromLocalStorage<T extends JsonValue>(key: string): T | undefined {
if (typeof localStorage === 'undefined') {
console.error('Local storage is not available.');
return undefined;
}
try {
const item = localStorage.getItem(key);
if (!item) {
console.warn(`Item with key ${key} does not exist in local storage.`);
return undefined;
}
return JSON.parse(item);
} catch (error) {
console.error(`Error getting item from local storage: ${error}`);
return undefined;
}
}
export function createLocalStorageInterface<T extends JsonValue>(
key: string,
): { load: () => T | undefined; save: (value: T) => void } {
return {
load: () => loadFromLocalStorage<T>(key),
save: (value: T) => saveToLocalStorage<T>(key, value),
};
}
export function usePersistToLocalStorage<T extends JsonValue>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
const storedValue = loadFromLocalStorage<T>(key);
return storedValue !== undefined ? storedValue : initialValue;
});
const saveValue = (dispatch: SetStateAction<T>) => {
if (typeof dispatch === 'function') {
setValue((prev) => {
const next = dispatch(prev);
saveToLocalStorage(key, next);
return next;
});
} else {
setValue(dispatch);
saveToLocalStorage(key, dispatch);
}
};
return [value, saveValue];
}

View File

@ -1,5 +1,6 @@
import { LocalAudioTrack, LocalVideoTrack, videoCodecs } from 'livekit-client';
import { VideoCodec } from 'livekit-client';
import { Dispatch } from 'react';
export interface SessionProps {
roomName: string;
@ -26,3 +27,33 @@ export type ConnectionDetails = {
participantName: string;
participantToken: string;
};
export type KeyBinding = {
eventName: keyof GlobalEventHandlersEventMap;
discriminator: (event: KeyboardEvent) => boolean;
target?: Window | Document | HTMLElement | string;
};
export type KeyBindings = Partial<
Record<KeyCommand, KeyBinding | [enable: KeyBinding, disable: KeyBinding]>
>;
export enum KeyCommand {
PTT = 'ptt',
ToggleMic = 'toggle-mic',
ToggleCamera = 'toggle-camera',
}
export type SettingsState = {
keybindings: KeyBindings;
enablePTT: boolean;
};
export type SettingsStateContextType = {
state: SettingsState;
set: Dispatch<React.SetStateAction<SettingsState>>;
};
export type SerializedSettingsState = Omit<SettingsState, 'keybindings'> & {
keybindings: Record<string, string | undefined>;
};

View File

@ -15,3 +15,10 @@
.tabs > .tab[aria-pressed='true'] {
border-color: var(--lk-accent-bg);
}
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}