Compare commits

...

5 Commits

Author SHA1 Message Date
rektdeckard
ae2650226e chore: rename discriminator -> guard 2025-05-23 10:18:53 -06:00
rektdeckard
782ee01481 chore: skip toggle action if an update is pending 2025-05-23 09:28:53 -06:00
rektdeckard
d82c04609d chore: cleanup 2025-05-22 22:15:00 -06:00
rektdeckard
97d9cb1013 fix: relax persistence type contraints 2025-05-22 19:02:07 -06:00
rektdeckard
2ccb2e53b9 feat: add PTT, keyboard shortcut engine, persistent auxiliary settings 2025-05-22 18:44:13 -06:00
9 changed files with 463 additions and 30 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 { useLocalParticipant, 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 { toggle: toggleCamera } = useTrackToggle({ source: Track.Source.Camera });
const { state } = useSettingsState();
const { localParticipant, isMicrophoneEnabled } = useLocalParticipant();
const { toggle: toggleMic, pending: pendingMicChange } = useTrackToggle({ source: Track.Source.Microphone });
const { toggle: toggleCamera, pending: pendingCameraChange } = useTrackToggle({ source: Track.Source.Camera });
const pttHeldRef = React.useRef(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, binding]) => {
switch (command) {
case KeyCommand.PTT:
if (!state.enablePTT || !Array.isArray(binding)) return [];
// Toggle camera: Cmd/Ctrl-Shift-V
if (event.key === 'V' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
toggleCamera();
}
}
const [enable, disable] = binding;
const t = getEventTarget(enable.target);
if (!t) return null;
window.addEventListener('keydown', handleShortcut);
return () => window.removeEventListener('keydown', handleShortcut);
}, [toggleMic, toggleCamera]);
const on = async (event: KeyboardEvent) => {
if (enable.guard(event)) {
event.preventDefault();
if (!isMicrophoneEnabled) {
pttHeldRef.current = true;
localParticipant?.setMicrophoneEnabled(true);
}
}
};
const off = async (event: KeyboardEvent) => {
if (disable.guard(event)) {
event.preventDefault();
if (pttHeldRef.current && isMicrophoneEnabled) {
pttHeldRef.current = false;
localParticipant?.setMicrophoneEnabled(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(binding)) {
const t = getEventTarget(binding.target);
if (!t) return null;
const handler = async (event: KeyboardEvent) => {
if (binding.guard(event) && !pendingMicChange) {
event.preventDefault();
toggleMic?.().catch(console.error);
}
};
t.addEventListener(binding.eventName, handler as any);
return { eventName: binding.eventName, target: t, handler };
}
case KeyCommand.ToggleCamera:
if (!Array.isArray(binding)) {
const t = getEventTarget(binding.target);
if (!t) return null;
const handler = async (event: KeyboardEvent) => {
if (binding.guard(event) && !pendingCameraChange) {
event.preventDefault();
toggleCamera?.().catch(console.error);
}
};
t.addEventListener(binding.eventName, handler as any);
return { eventName: binding.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, toggleCamera, pendingCameraChange, toggleMic, pendingMicChange, localParticipant, isMicrophoneEnabled]);
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>
);
}

94
lib/SettingsContext.tsx Normal file
View File

@ -0,0 +1,94 @@
'use client';
import React, { createContext, SetStateAction, useCallback, useContext, useMemo } from 'react';
import type {
SettingsState,
SettingsStateContextType,
SerializedSettingsState,
KeyBindings,
} 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<Record<string, string>>(
(acc, [key, value]) => {
const commonName = Object.entries(commonKeyBindings).find(([_, v]) => v === value)?.[0];
if (commonName) {
acc[key] = commonName;
}
return acc;
},
{},
),
};
}
function deserializeSettingsState(state: SerializedSettingsState): SettingsState {
return {
...state,
keybindings: {
...defaultKeyBindings,
...Object.entries(state.keybindings).reduce<KeyBindings>((acc, [key, commonName]) => {
const commonBinding = commonKeyBindings[commonName as keyof typeof commonKeyBindings];
if (commonBinding) {
acc[key as keyof typeof defaultKeyBindings] = commonBinding;
}
return acc;
}, {}),
},
};
}
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]);
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 isInteractiveElement(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',
guard: (event) => {
return event.code === 'Space' && !isInteractiveElement(event);
},
},
{
eventName: 'keyup',
guard: (event) => event.code === 'Space',
},
],
leftMouse: [
{
eventName: 'mousedown',
guard: (event) => {
return isMouseButton(0, event) && !isInteractiveElement(event);
},
},
{
eventName: 'mouseup',
guard: (event) => isMouseButton(0, event),
},
],
middleMouse: [
{
eventName: 'mousedown',
guard: (event) => isMouseButton(1, event),
},
{
eventName: 'mouseup',
guard: (event) => isMouseButton(1, event),
},
],
metaShiftA: {
eventName: 'keydown',
guard: (event) => event.key === 'A' && (event.ctrlKey || event.metaKey),
},
metaShiftV: {
eventName: 'keydown',
guard: (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,
},
],
};

74
lib/persistence.ts Normal file
View File

@ -0,0 +1,74 @@
'use client';
import { Dispatch, SetStateAction, useState } from 'react';
function saveToLocalStorage<T extends object>(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 object>(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 object>(
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 object>(
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;
guard: (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>;
};

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;
}