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
27 changed files with 869 additions and 1609 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 B

View File

@ -1,16 +1,33 @@
# .github/workflows/sync-to-production.yaml
name: Sync main to sandbox-production
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: livekit-examples/sandbox-deploy-action@v1
- name: Checkout code
uses: actions/checkout@v4
with:
production_branch: 'sandbox-production'
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch all history so we can force push
- name: Set up Git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@livekit.io'
- name: Sync to sandbox-production
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git checkout sandbox-production || git checkout -b sandbox-production
git merge --strategy-option theirs main
git push origin sandbox-production

View File

@ -1,32 +0,0 @@
name: Test
on:
push:
branches: [ main ]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: ESLint
run: pnpm lint
- name: Prettier
run: pnpm format:check
- name: Run Tests
run: pnpm test

View File

@ -1,5 +1,4 @@
import { randomString } from '@/lib/client-utils';
import { getLiveKitURL } from '@/lib/getLiveKitURL';
import { ConnectionDetails } from '@/lib/types';
import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
import { NextRequest, NextResponse } from 'next/server';
@ -7,7 +6,6 @@ import { NextRequest, NextResponse } from 'next/server';
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
const COOKIE_KEY = 'random-participant-postfix';
export async function GET(request: NextRequest) {
@ -17,10 +15,7 @@ export async function GET(request: NextRequest) {
const participantName = request.nextUrl.searchParams.get('participantName');
const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
const region = request.nextUrl.searchParams.get('region');
if (!LIVEKIT_URL) {
throw new Error('LIVEKIT_URL is not defined');
}
const livekitServerUrl = region ? getLiveKitURL(LIVEKIT_URL, region) : LIVEKIT_URL;
const livekitServerUrl = region ? getLiveKitURL(region) : LIVEKIT_URL;
let randomParticipantPostfix = request.cookies.get(COOKIE_KEY)?.value;
if (livekitServerUrl === undefined) {
throw new Error('Invalid region');
@ -80,6 +75,21 @@ function createParticipantToken(userInfo: AccessTokenOptions, roomName: string)
return at.toJwt();
}
/**
* Get the LiveKit server URL for the given region.
*/
function getLiveKitURL(region: string | null): string {
let targetKey = 'LIVEKIT_URL';
if (region) {
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
}
const url = process.env[targetKey];
if (!url) {
throw new Error(`${targetKey} is not defined`);
}
return url;
}
function getCookieExpirationTime(): string {
var now = new Date();
var time = now.getTime();

View File

@ -11,24 +11,24 @@ import {
type VideoCodec,
} from 'livekit-client';
import { DebugMode } from '@/lib/Debug';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { useSetupE2EE } from '@/lib/useSetupE2EE';
import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
export function VideoConferenceClientImpl(props: {
liveKitUrl: string;
token: string;
codec: VideoCodec | undefined;
singlePeerConnection: boolean | undefined;
}) {
const worker =
typeof window !== 'undefined' &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const keyProvider = new ExternalE2EEKeyProvider();
const { worker, e2eePassphrase } = useSetupE2EE();
const e2eePassphrase =
typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
const e2eeEnabled = !!(e2eePassphrase && worker);
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false);
const roomOptions = useMemo((): RoomOptions => {
return {
publishDefaults: {
@ -44,12 +44,14 @@ export function VideoConferenceClientImpl(props: {
worker,
}
: undefined,
singlePeerConnection: props.singlePeerConnection,
};
}, [e2eeEnabled, props.codec, keyProvider, worker]);
const room = useMemo(() => new Room(roomOptions), [roomOptions]);
}, []);
const room = useMemo(() => new Room(roomOptions), []);
if (e2eeEnabled) {
keyProvider.setKey(e2eePassphrase);
room.setE2EEEnabled(true);
}
const connectOptions = useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
@ -57,29 +59,13 @@ export function VideoConferenceClientImpl(props: {
}, []);
useEffect(() => {
if (e2eeEnabled) {
keyProvider.setKey(e2eePassphrase).then(() => {
room.setE2EEEnabled(true).then(() => {
setE2eeSetupComplete(true);
});
});
} else {
setE2eeSetupComplete(true);
}
}, [e2eeEnabled, e2eePassphrase, keyProvider, room, setE2eeSetupComplete]);
useEffect(() => {
if (e2eeSetupComplete) {
room.connect(props.liveKitUrl, props.token, connectOptions).catch((error) => {
console.error(error);
});
room.localParticipant.enableCameraAndMicrophone().catch((error) => {
console.error(error);
});
}
}, [room, props.liveKitUrl, props.token, connectOptions, e2eeSetupComplete]);
useLowCPUOptimizer(room);
room.connect(props.liveKitUrl, props.token, connectOptions).catch((error) => {
console.error(error);
});
room.localParticipant.enableCameraAndMicrophone().catch((error) => {
console.error(error);
});
}, [room, props.liveKitUrl, props.token, connectOptions]);
return (
<div className="lk-room-container">

View File

@ -7,10 +7,9 @@ export default async function CustomRoomConnection(props: {
liveKitUrl?: string;
token?: string;
codec?: string;
singlePC?: string;
}>;
}) {
const { liveKitUrl, token, codec, singlePC } = await props.searchParams;
const { liveKitUrl, token, codec } = await props.searchParams;
if (typeof liveKitUrl !== 'string') {
return <h2>Missing LiveKit URL</h2>;
}
@ -23,12 +22,7 @@ export default async function CustomRoomConnection(props: {
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
<VideoConferenceClientImpl
liveKitUrl={liveKitUrl}
token={token}
codec={codec}
singlePeerConnection={singlePC === 'true'}
/>
<VideoConferenceClientImpl liveKitUrl={liveKitUrl} token={token} codec={codec} />
</main>
);
}

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

@ -23,12 +23,8 @@ import {
DeviceUnsupportedError,
RoomConnectOptions,
RoomEvent,
TrackPublishDefaults,
VideoCaptureOptions,
} from 'livekit-client';
import { useRouter } from 'next/navigation';
import { useSetupE2EE } from '@/lib/useSetupE2EE';
import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
@ -97,10 +93,15 @@ function VideoConferenceComponent(props: {
codec: VideoCodec;
};
}) {
const keyProvider = new ExternalE2EEKeyProvider();
const { worker, e2eePassphrase } = useSetupE2EE();
const e2eeEnabled = !!(e2eePassphrase && worker);
const e2eePassphrase =
typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1));
const worker =
typeof window !== 'undefined' &&
e2eePassphrase &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const e2eeEnabled = !!(e2eePassphrase && worker);
const keyProvider = new ExternalE2EEKeyProvider();
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
const roomOptions = React.useMemo((): RoomOptions => {
@ -108,28 +109,30 @@ function VideoConferenceComponent(props: {
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
const videoCaptureDefaults: VideoCaptureOptions = {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
};
const publishDefaults: TrackPublishDefaults = {
dtx: false,
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
};
return {
videoCaptureDefaults: videoCaptureDefaults,
publishDefaults: publishDefaults,
videoCaptureDefaults: {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
},
publishDefaults: {
dtx: false,
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
},
audioCaptureDefaults: {
deviceId: props.userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: true,
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: keyProvider && worker && e2eeEnabled ? { keyProvider, worker } : undefined,
singlePeerConnection: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
};
}, [props.userChoices, props.options.hq, props.options.codec]);
@ -167,7 +170,6 @@ function VideoConferenceComponent(props: {
room.on(RoomEvent.Disconnected, handleOnLeave);
room.on(RoomEvent.EncryptionError, handleEncryptionError);
room.on(RoomEvent.MediaDevicesError, handleError);
if (e2eeSetupComplete) {
room
.connect(
@ -196,8 +198,6 @@ function VideoConferenceComponent(props: {
};
}, [e2eeSetupComplete, room, props.connectionDetails, props.userChoices]);
const lowPowerMode = useLowCPUOptimizer(room);
const router = useRouter();
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
const handleError = React.useCallback((error: Error) => {
@ -211,12 +211,6 @@ function VideoConferenceComponent(props: {
);
}, []);
React.useEffect(() => {
if (lowPowerMode) {
console.warn('Low power mode enabled');
}
}, [lowPowerMode]);
return (
<div className="lk-room-container">
<RoomContext.Provider value={room}>

View File

@ -1,7 +1,6 @@
import React from 'react';
import {
MediaDeviceMenu,
TrackReference,
TrackToggle,
useLocalParticipant,
VideoTrack,
@ -34,10 +33,8 @@ export function CameraSettings() {
null,
);
const camTrackRef: TrackReference | undefined = React.useMemo(() => {
return cameraTrack
? { participant: localParticipant, publication: cameraTrack, source: Track.Source.Camera }
: undefined;
const camTrackRef = React.useMemo(() => {
return { participant: localParticipant, publication: cameraTrack, source: Track.Source.Camera };
}, [localParticipant, cameraTrack]);
const selectBackground = (type: BackgroundType, imagePath?: string) => {
@ -63,17 +60,15 @@ export function CameraSettings() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{camTrackRef && (
<VideoTrack
style={{
maxHeight: '280px',
objectFit: 'contain',
objectPosition: 'right',
transform: 'scaleX(-1)',
}}
trackRef={camTrackRef}
/>
)}
<VideoTrack
style={{
maxHeight: '280px',
objectFit: 'contain',
objectPosition: 'right',
transform: 'scaleX(-1)',
}}
trackRef={camTrackRef}
/>
<section className="lk-button-group">
<TrackToggle source={Track.Source.Camera}>Camera</TrackToggle>

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

View File

@ -3,27 +3,14 @@ import { useKrispNoiseFilter } from '@livekit/components-react/krisp';
import { TrackToggle } from '@livekit/components-react';
import { MediaDeviceMenu } from '@livekit/components-react';
import { Track } from 'livekit-client';
import { isLowPowerDevice } from './client-utils';
export function MicrophoneSettings() {
const { isNoiseFilterEnabled, setNoiseFilterEnabled, isNoiseFilterPending } = useKrispNoiseFilter(
{
filterOptions: {
bufferOverflowMs: 100,
bufferDropMs: 200,
quality: isLowPowerDevice() ? 'low' : 'medium',
onBufferDrop: () => {
console.warn(
'krisp buffer dropped, noise filter versions >= 0.3.2 will automatically disable the filter',
);
},
},
},
);
const { isNoiseFilterEnabled, setNoiseFilterEnabled, isNoiseFilterPending } =
useKrispNoiseFilter();
React.useEffect(() => {
// enable Krisp by default on non-low power devices
setNoiseFilterEnabled(!isLowPowerDevice());
// enable Krisp by default
setNoiseFilterEnabled(true);
}, []);
return (
<div

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

View File

@ -19,11 +19,3 @@ export function randomString(length: number): string {
}
return result;
}
export function isLowPowerDevice() {
return navigator.hardwareConcurrency < 6;
}
export function isMeetStaging() {
return new URL(location.origin).host === 'meet.staging.livekit.io';
}

View File

@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { getLiveKitURL } from './getLiveKitURL';
describe('getLiveKitURL', () => {
it('returns the original URL if no region is provided', () => {
const url = 'https://myproject.livekit.cloud';
expect(getLiveKitURL(url, null)).toBe(url + '/');
});
it('inserts the region into livekit.cloud URLs', () => {
const url = 'https://myproject.livekit.cloud';
const region = 'eu';
expect(getLiveKitURL(url, region)).toBe('https://myproject.eu.production.livekit.cloud/');
});
it('inserts the region into livekit.cloud URLs and preserves the staging environment', () => {
const url = 'https://myproject.staging.livekit.cloud';
const region = 'eu';
expect(getLiveKitURL(url, region)).toBe('https://myproject.eu.staging.livekit.cloud/');
});
it('returns the original URL for non-livekit.cloud hosts, even with region', () => {
const url = 'https://example.com';
const region = 'us';
expect(getLiveKitURL(url, region)).toBe(url + '/');
});
it('handles URLs with paths and query params', () => {
const url = 'https://myproject.livekit.cloud/room?foo=bar';
const region = 'ap';
expect(getLiveKitURL(url, region)).toBe(
'https://myproject.ap.production.livekit.cloud/room?foo=bar',
);
});
});

View File

@ -1,12 +0,0 @@
export function getLiveKitURL(projectUrl: string, region: string | null): string {
const url = new URL(projectUrl);
if (region && url.hostname.includes('livekit.cloud')) {
let [projectId, ...hostParts] = url.hostname.split('.');
if (hostParts[0] !== 'staging') {
hostParts = ['production', ...hostParts];
}
const regionURL = [projectId, region, ...hostParts].join('.');
url.hostname = regionURL;
}
return url.toString();
}

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

@ -1,71 +0,0 @@
import {
Room,
ParticipantEvent,
RoomEvent,
RemoteTrack,
RemoteTrackPublication,
VideoQuality,
LocalVideoTrack,
isVideoTrack,
} from 'livekit-client';
import * as React from 'react';
export type LowCPUOptimizerOptions = {
reducePublisherVideoQuality: boolean;
reduceSubscriberVideoQuality: boolean;
disableVideoProcessing: boolean;
};
const defaultOptions: LowCPUOptimizerOptions = {
reducePublisherVideoQuality: true,
reduceSubscriberVideoQuality: true,
disableVideoProcessing: false,
} as const;
/**
* This hook ensures that on devices with low CPU, the performance is optimised when needed.
* This is done by primarily reducing the video quality to low when the CPU is constrained.
*/
export function useLowCPUOptimizer(room: Room, options: Partial<LowCPUOptimizerOptions> = {}) {
const [lowPowerMode, setLowPowerMode] = React.useState(false);
const opts = React.useMemo(() => ({ ...defaultOptions, ...options }), [options]);
React.useEffect(() => {
const handleCpuConstrained = async (track: LocalVideoTrack) => {
setLowPowerMode(true);
console.warn('Local track CPU constrained', track);
if (opts.reducePublisherVideoQuality) {
track.prioritizePerformance();
}
if (opts.disableVideoProcessing && isVideoTrack(track)) {
track.stopProcessor();
}
if (opts.reduceSubscriberVideoQuality) {
room.remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
publication.setVideoQuality(VideoQuality.LOW);
});
});
}
};
room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, handleCpuConstrained);
return () => {
room.localParticipant.off(ParticipantEvent.LocalTrackCpuConstrained, handleCpuConstrained);
};
}, [room, opts.reducePublisherVideoQuality, opts.reduceSubscriberVideoQuality]);
React.useEffect(() => {
const lowerQuality = (_: RemoteTrack, publication: RemoteTrackPublication) => {
publication.setVideoQuality(VideoQuality.LOW);
};
if (lowPowerMode && opts.reduceSubscriberVideoQuality) {
room.on(RoomEvent.TrackSubscribed, lowerQuality);
}
return () => {
room.off(RoomEvent.TrackSubscribed, lowerQuality);
};
}, [lowPowerMode, room, opts.reduceSubscriberVideoQuality]);
return lowPowerMode;
}

View File

@ -1,15 +0,0 @@
import React from 'react';
import { ExternalE2EEKeyProvider } from 'livekit-client';
import { decodePassphrase } from './client-utils';
export function useSetupE2EE() {
const e2eePassphrase =
typeof window !== 'undefined' ? decodePassphrase(location.hash.substring(1)) : undefined;
const worker: Worker | undefined =
typeof window !== 'undefined' && e2eePassphrase
? new Worker(new URL('livekit-client/e2ee-worker', import.meta.url))
: undefined;
return { worker, e2eePassphrase };
}

View File

@ -15,23 +15,6 @@ const nextConfig = {
return config;
},
headers: async () => {
return [
{
source: '/(.*)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'credentialless',
},
],
},
];
},
};
module.exports = nextConfig;

View File

@ -8,37 +8,34 @@
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"test": "vitest run",
"format:check": "prettier --check \"**/*.{ts,tsx,md,json}\"",
"format:write": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"dependencies": {
"@datadog/browser-logs": "^5.23.3",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/krisp-noise-filter": "0.4.1",
"@livekit/track-processors": "^0.7.0",
"livekit-client": "2.17.2",
"livekit-server-sdk": "2.15.0",
"next": "15.2.8",
"@livekit/components-react": "2.9.5",
"@livekit/components-styles": "1.1.6",
"@livekit/krisp-noise-filter": "0.3.0",
"@livekit/track-processors": "^0.5.4",
"livekit-client": "2.13.1",
"livekit-server-sdk": "2.13.0",
"next": "15.2.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"tinykeys": "^3.0.0"
},
"devDependencies": {
"@types/node": "24.10.13",
"@types/react": "18.3.27",
"@types/node": "22.15.19",
"@types/react": "18.3.21",
"@types/react-dom": "18.3.7",
"eslint": "9.39.1",
"eslint-config-next": "15.5.6",
"prettier": "3.7.3",
"eslint": "9.27.0",
"eslint-config-next": "15.3.2",
"source-map-loader": "^5.0.0",
"typescript": "5.9.3",
"vitest": "^3.2.4"
"typescript": "5.8.3"
},
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@10.18.2"
"packageManager": "pnpm@9.15.9"
}

1539
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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