diff --git a/app/layout.tsx b/app/layout.tsx index a812f43a..0d1a67f3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - {children} + {children} ); diff --git a/lib/KeyboardShortcuts.tsx b/lib/KeyboardShortcuts.tsx index 5a0c659e..10075c44 100644 --- a/lib/KeyboardShortcuts.tsx +++ b/lib/KeyboardShortcuts.tsx @@ -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(); - } - - // 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, binding]) => { + switch (command) { + case KeyCommand.PTT: + if (!state.enablePTT || !Array.isArray(binding)) return []; + + const [enable, disable] = binding; + const t = getEventTarget(enable.target); + if (!t) return null; + + 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; +} diff --git a/lib/Providers.tsx b/lib/Providers.tsx new file mode 100644 index 00000000..899f8798 --- /dev/null +++ b/lib/Providers.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Toaster } from 'react-hot-toast'; +import { SettingsStateProvider } from './SettingsContext'; + +export function Providers({ children }: React.PropsWithChildren) { + return ( + + + {children} + + ); +} diff --git a/lib/SettingsContext.tsx b/lib/SettingsContext.tsx new file mode 100644 index 00000000..792f37e1 --- /dev/null +++ b/lib/SettingsContext.tsx @@ -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>( + (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((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({ + state: initialState, + set: () => { }, +}); + +const SettingsStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, set] = usePersistToLocalStorage( + AUXILIARY_USER_CHOICES_KEY, + serializeSettingsState(initialState), + ); + + const deserializedState = useMemo(() => deserializeSettingsState(state), [state]); + + const setSettingsState = useCallback( + (dispatch: SetStateAction) => { + if (typeof dispatch === 'function') { + set((prev) => { + const next = serializeSettingsState(dispatch(deserializeSettingsState(prev))); + return next; + }); + } else { + set(serializeSettingsState(dispatch)); + } + }, + [set], + ); + + return ( + + {children} + + ); +}; + +const useSettingsState = () => { + const ctx = useContext(SettingsStateContext); + if (ctx === null) { + throw new Error('useSettingsState must be used within SettingsStateProvider'); + } + return ctx!; +}; + +export { useSettingsState, SettingsStateProvider, SettingsStateContext }; diff --git a/lib/SettingsMenu.tsx b/lib/SettingsMenu.tsx index 9cf7c35e..8273eed2 100644 --- a/lib/SettingsMenu.tsx +++ b/lib/SettingsMenu.tsx @@ -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 * @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 (
@@ -85,10 +102,7 @@ export function SettingsMenu(props: SettingsMenuProps) { onClick={() => setActiveTab(tab)} aria-pressed={tab === activeTab} > - { - // @ts-ignore - settings[tab].label - } + {settings[tab].label} ), )} @@ -140,6 +154,36 @@ export function SettingsMenu(props: SettingsMenuProps) { )} + {activeTab === 'keyboard' && ( + <> +

PTT

+
+ +
+

PTT trigger

+
+ {settings.keyboard.keybindings[KeyCommand.PTT]?.map(({ label, binds }) => ( +
+ setKeyBinding(KeyCommand.PTT, binds)} + /> + +
+ ))} +
+ + )}