Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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>
);
Expand Down
118 changes: 98 additions & 20 deletions lib/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -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();
}

// Toggle camera: Cmd/Ctrl-Shift-V
if (event.key === 'V' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
toggleCamera();
}
}

window.addEventListener('keydown', handleShortcut);
return () => window.removeEventListener('keydown', handleShortcut);
}, [toggleMic, toggleCamera]);
const handlers = Object.entries(state.keybindings)
.flatMap(([command, bind]) => {
switch (command) {
case KeyCommand.PTT:
if (!state.enablePTT || !Array.isArray(bind)) return [];

const [enable, disable] = bind;
const t = getEventTarget(enable.target);
if (!t) return null;

const on = (event: KeyboardEvent) => {
if (enable.discriminator(event)) {
event.preventDefault();
if (!micEnabled) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will race. The useTrackToggle hook also returns pending state which is between state change and its state change trigger.

Generally explicitly muting/unmuting might be a better fit for PTT than toggling ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. And now checking the pending state before firing another toggle action in the other cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anecdotally, I have spammed both PTT and toggle as fast as I can and don't see any unexpected behavior.

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 changes: 13 additions & 0 deletions lib/Providers.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 94 additions & 0 deletions lib/SettingsContext.tsx
Original file line number Diff line number Diff line change
@@ -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 };
58 changes: 51 additions & 7 deletions lib/SettingsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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}>
Expand All @@ -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>
),
)}
Expand Down Expand Up @@ -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
Expand Down
Loading