Skip to content

Commit 2ccb2e5

Browse files
committed
feat: add PTT, keyboard shortcut engine, persistent auxiliary settings
1 parent e418eea commit 2ccb2e5

File tree

9 files changed

+471
-30
lines changed

9 files changed

+471
-30
lines changed

app/layout.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import '../styles/globals.css';
22
import '@livekit/components-styles';
33
import '@livekit/components-styles/prefabs';
44
import type { Metadata, Viewport } from 'next';
5-
import { Toaster } from 'react-hot-toast';
5+
import { Providers } from '@/lib/Providers';
66

77
export const metadata: Metadata = {
88
title: {
@@ -52,8 +52,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
5252
return (
5353
<html lang="en">
5454
<body data-lk-theme="default">
55-
<Toaster />
56-
{children}
55+
<Providers>{children}</Providers>
5756
</body>
5857
</html>
5958
);

lib/KeyboardShortcuts.tsx

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,109 @@
1-
'use client';
2-
31
import React from 'react';
42
import { Track } from 'livekit-client';
53
import { useTrackToggle } from '@livekit/components-react';
4+
import { useSettingsState } from './SettingsContext';
5+
import { KeyCommand } from './types';
66

77
export function KeyboardShortcuts() {
8-
const { toggle: toggleMic } = useTrackToggle({ source: Track.Source.Microphone });
8+
const { state } = useSettingsState() ?? {};
9+
const { toggle: toggleMic, enabled: micEnabled } = useTrackToggle({
10+
source: Track.Source.Microphone,
11+
});
912
const { toggle: toggleCamera } = useTrackToggle({ source: Track.Source.Camera });
13+
const [pttHeld, setPttHeld] = React.useState(false);
1014

1115
React.useEffect(() => {
12-
function handleShortcut(event: KeyboardEvent) {
13-
// Toggle microphone: Cmd/Ctrl-Shift-A
14-
if (toggleMic && event.key === 'A' && (event.ctrlKey || event.metaKey)) {
15-
event.preventDefault();
16-
toggleMic();
17-
}
18-
19-
// Toggle camera: Cmd/Ctrl-Shift-V
20-
if (event.key === 'V' && (event.ctrlKey || event.metaKey)) {
21-
event.preventDefault();
22-
toggleCamera();
23-
}
24-
}
25-
26-
window.addEventListener('keydown', handleShortcut);
27-
return () => window.removeEventListener('keydown', handleShortcut);
28-
}, [toggleMic, toggleCamera]);
16+
const handlers = Object.entries(state.keybindings)
17+
.flatMap(([command, bind]) => {
18+
switch (command) {
19+
case KeyCommand.PTT:
20+
if (!state.enablePTT || !Array.isArray(bind)) return [];
21+
22+
const [enable, disable] = bind;
23+
const t = getEventTarget(enable.target);
24+
if (!t) return null;
25+
26+
const on = (event: KeyboardEvent) => {
27+
if (enable.discriminator(event)) {
28+
event.preventDefault();
29+
if (!micEnabled) {
30+
setPttHeld(true);
31+
toggleMic?.(true);
32+
}
33+
}
34+
};
35+
36+
const off = (event: KeyboardEvent) => {
37+
if (disable.discriminator(event)) {
38+
event.preventDefault();
39+
if (pttHeld && micEnabled) {
40+
setPttHeld(false);
41+
toggleMic?.(false);
42+
}
43+
}
44+
};
45+
46+
t.addEventListener(enable.eventName, on as any);
47+
t.addEventListener(disable.eventName, off as any);
48+
return [
49+
{ eventName: enable.eventName, target: t, handler: on },
50+
{ eventName: disable.eventName, target: t, handler: off },
51+
];
52+
case KeyCommand.ToggleMic:
53+
if (!Array.isArray(bind)) {
54+
const t = getEventTarget(bind.target);
55+
if (!t) return null;
56+
57+
const handler = (event: KeyboardEvent) => {
58+
if (bind.discriminator(event)) {
59+
event.preventDefault();
60+
toggleMic?.();
61+
}
62+
};
63+
t.addEventListener(bind.eventName, handler as any);
64+
return { eventName: bind.eventName, target: t, handler };
65+
}
66+
case KeyCommand.ToggleCamera:
67+
if (!Array.isArray(bind)) {
68+
const t = getEventTarget(bind.target);
69+
if (!t) return null;
70+
71+
const handler = (event: KeyboardEvent) => {
72+
if (bind.discriminator(event)) {
73+
event.preventDefault();
74+
toggleCamera?.();
75+
}
76+
};
77+
t.addEventListener(bind.eventName, handler as any);
78+
return { eventName: bind.eventName, target: t, handler };
79+
}
80+
default:
81+
return [];
82+
}
83+
})
84+
.filter(Boolean) as Array<{
85+
target: EventTarget;
86+
eventName: string;
87+
handler: (event: KeyboardEvent) => void;
88+
}>;
89+
90+
return () => {
91+
handlers.forEach(({ target, eventName, handler }) => {
92+
target.removeEventListener(eventName, handler as any);
93+
});
94+
};
95+
}, [state, pttHeld, micEnabled, toggleMic]);
2996

3097
return null;
3198
}
99+
100+
function getEventTarget(
101+
target: Window | Document | HTMLElement | string = window,
102+
): EventTarget | null {
103+
const targetElement = typeof target === 'string' ? document.querySelector(target) : target;
104+
if (!targetElement) {
105+
console.warn(`Target element not found for ${target}`);
106+
return null;
107+
}
108+
return targetElement;
109+
}

lib/Providers.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client';
2+
3+
import { Toaster } from 'react-hot-toast';
4+
import { SettingsStateProvider } from './SettingsContext';
5+
6+
export function Providers({ children }: React.PropsWithChildren) {
7+
return (
8+
<SettingsStateProvider>
9+
<Toaster />
10+
{children}
11+
</SettingsStateProvider>
12+
);
13+
}

lib/SettingsContext.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import React, { createContext, SetStateAction, useCallback, useContext, useMemo } from 'react';
4+
import type {
5+
SettingsState,
6+
SettingsStateContextType,
7+
SerializedSettingsState,
8+
KeyBindings,
9+
KeyCommand,
10+
} from './types';
11+
import { defaultKeyBindings, commonKeyBindings } from './keybindings';
12+
import { usePersistToLocalStorage } from './persistence';
13+
14+
const AUXILIARY_USER_CHOICES_KEY = `lk-auxiliary-user-choices`;
15+
16+
const initialState: SettingsState = {
17+
keybindings: defaultKeyBindings,
18+
enablePTT: false,
19+
};
20+
21+
function serializeSettingsState(state: SettingsState): SerializedSettingsState {
22+
return {
23+
...state,
24+
keybindings: Object.entries(state.keybindings).reduce(
25+
(acc, [key, value]) => {
26+
const commonName = Object.entries(commonKeyBindings).find(([_, v]) => v === value)?.[0];
27+
if (commonName) {
28+
acc[key] = commonName;
29+
}
30+
return acc;
31+
},
32+
{} as Record<string, string>,
33+
),
34+
};
35+
}
36+
37+
function deserializeSettingsState(state: SerializedSettingsState): SettingsState {
38+
return {
39+
...state,
40+
keybindings: {
41+
...defaultKeyBindings,
42+
...Object.entries(state.keybindings).reduce((acc, [key, commonName]) => {
43+
const commonBinding = commonKeyBindings[commonName as keyof typeof commonKeyBindings];
44+
if (commonBinding) {
45+
acc[key as keyof typeof defaultKeyBindings] = commonBinding;
46+
}
47+
return acc;
48+
}, {} as KeyBindings),
49+
},
50+
};
51+
}
52+
53+
const SettingsStateContext = createContext<SettingsStateContextType>({
54+
state: initialState,
55+
set: () => {},
56+
});
57+
58+
const SettingsStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
59+
const [state, set] = usePersistToLocalStorage<SerializedSettingsState>(
60+
AUXILIARY_USER_CHOICES_KEY,
61+
serializeSettingsState(initialState),
62+
);
63+
64+
const deserializedState = useMemo(() => deserializeSettingsState(state), [state]);
65+
66+
console.info({ deserializedState });
67+
68+
const setSettingsState = useCallback(
69+
(dispatch: SetStateAction<SettingsState>) => {
70+
if (typeof dispatch === 'function') {
71+
set((prev) => {
72+
const next = serializeSettingsState(dispatch(deserializeSettingsState(prev)));
73+
return next;
74+
});
75+
} else {
76+
set(serializeSettingsState(dispatch));
77+
}
78+
},
79+
[set],
80+
);
81+
82+
return (
83+
<SettingsStateContext.Provider value={{ state: deserializedState, set: setSettingsState }}>
84+
{children}
85+
</SettingsStateContext.Provider>
86+
);
87+
};
88+
89+
const useSettingsState = () => {
90+
const ctx = useContext(SettingsStateContext);
91+
if (ctx === null) {
92+
throw new Error('useSettingsState must be used within SettingsStateProvider');
93+
}
94+
return ctx!;
95+
};
96+
97+
export { useSettingsState, SettingsStateProvider, SettingsStateContext };

lib/SettingsMenu.tsx

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
'use client';
2+
23
import * as React from 'react';
3-
import { Track } from 'livekit-client';
44
import {
55
useMaybeLayoutContext,
66
MediaDeviceMenu,
7-
TrackToggle,
87
useRoomContext,
98
useIsRecording,
109
} from '@livekit/components-react';
1110
import styles from '../styles/SettingsMenu.module.css';
1211
import { CameraSettings } from './CameraSettings';
1312
import { MicrophoneSettings } from './MicrophoneSettings';
13+
import { useSettingsState } from './SettingsContext';
14+
import { KeyBinding, KeyCommand } from './types';
15+
import { keybindingOptions } from './keybindings';
1416
/**
1517
* @alpha
1618
*/
@@ -20,6 +22,7 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
2022
* @alpha
2123
*/
2224
export function SettingsMenu(props: SettingsMenuProps) {
25+
const { state, set: setSettingsState } = useSettingsState();
2326
const layoutContext = useMaybeLayoutContext();
2427
const room = useRoomContext();
2528
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
@@ -28,7 +31,11 @@ export function SettingsMenu(props: SettingsMenuProps) {
2831
return {
2932
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
3033
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
31-
};
34+
keyboard: {
35+
label: 'Keybindings',
36+
keybindings: keybindingOptions,
37+
},
38+
} as const;
3239
}, []);
3340

3441
const tabs = React.useMemo(
@@ -73,6 +80,16 @@ export function SettingsMenu(props: SettingsMenuProps) {
7380
}
7481
};
7582

83+
const setKeyBinding = (key: KeyCommand, binds: KeyBinding | [KeyBinding, KeyBinding]) => {
84+
setSettingsState((prev) => ({
85+
...prev,
86+
keybindings: {
87+
...prev.keybindings,
88+
[key]: binds,
89+
},
90+
}));
91+
};
92+
7693
return (
7794
<div className="settings-menu" style={{ width: '100%', position: 'relative' }} {...props}>
7895
<div className={styles.tabs}>
@@ -85,10 +102,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
85102
onClick={() => setActiveTab(tab)}
86103
aria-pressed={tab === activeTab}
87104
>
88-
{
89-
// @ts-ignore
90-
settings[tab].label
91-
}
105+
{settings[tab].label}
92106
</button>
93107
),
94108
)}
@@ -140,6 +154,36 @@ export function SettingsMenu(props: SettingsMenuProps) {
140154
</section>
141155
</>
142156
)}
157+
{activeTab === 'keyboard' && (
158+
<>
159+
<h3>PTT</h3>
160+
<section>
161+
<button
162+
className="lk-button"
163+
onClick={() => {
164+
setSettingsState((prev) => ({ ...prev, enablePTT: !prev.enablePTT }));
165+
}}
166+
>
167+
{`${state.enablePTT ? 'Disable' : 'Enable'} PTT`}
168+
</button>
169+
</section>
170+
<h4>PTT trigger</h4>
171+
<section>
172+
{settings.keyboard.keybindings[KeyCommand.PTT]?.map(({ label, binds }) => (
173+
<div key={label}>
174+
<input
175+
type="radio"
176+
name="ptt"
177+
id={label}
178+
defaultChecked={state.keybindings[KeyCommand.PTT] === binds}
179+
onChange={() => setKeyBinding(KeyCommand.PTT, binds)}
180+
/>
181+
<label htmlFor={label}>{label}</label>
182+
</div>
183+
))}
184+
</section>
185+
</>
186+
)}
143187
</div>
144188
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
145189
<button

0 commit comments

Comments
 (0)