Compare commits
52 Commits
tobias/ptt
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cee3ed06 | ||
|
|
2220072d47 | ||
|
|
392ca136de | ||
|
|
3a75f3222f | ||
|
|
f80673aba8 | ||
|
|
690dc1011a | ||
|
|
6de1bc8cc6 | ||
|
|
563925f757 | ||
|
|
0b62ed930e | ||
|
|
baa4e787a2 | ||
|
|
dc82cc23b9 | ||
|
|
e9b037bac1 | ||
|
|
49b83637dc | ||
|
|
aa9be8cdc0 | ||
|
|
03aac6591a | ||
|
|
83424b27d5 | ||
|
|
55adec00d3 | ||
|
|
5ff6fa32ac | ||
|
|
8e66391a01 | ||
|
|
e9dba9861a | ||
|
|
76234cdf93 | ||
|
|
0b4af83a3f | ||
|
|
6fdf7f0b9a | ||
|
|
372cdfe760 | ||
|
|
fcec3a2459 | ||
|
|
7d1d62b6c3 | ||
|
|
aa310ade64 | ||
|
|
762f1c4a6e | ||
|
|
6ce2570868 | ||
|
|
785359f977 | ||
|
|
26d90de86c | ||
|
|
f13f8df08e | ||
|
|
4dd11f412b | ||
|
|
a8a48d5a7f | ||
|
|
68046da53c | ||
|
|
8826f588a0 | ||
|
|
3f4b5a14b9 | ||
|
|
f6cdb176e9 | ||
|
|
3260877886 | ||
|
|
e1954a739d | ||
|
|
03fda24b39 | ||
|
|
2e35ce825e | ||
|
|
62df7245a3 | ||
|
|
b650fecdd4 | ||
|
|
5230af4fb6 | ||
|
|
8736088a7e | ||
|
|
5619c99aa9 | ||
|
|
c99a780f58 | ||
|
|
c4ea8a31ec | ||
|
|
bfde08ea91 | ||
|
|
96b193098d | ||
|
|
b3b8901cf7 |
BIN
.github/assets/template-dark.webp
vendored
Normal file
BIN
.github/assets/template-dark.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 758 B |
BIN
.github/assets/template-light.webp
vendored
Normal file
BIN
.github/assets/template-light.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 794 B |
31
.github/workflows/sync-to-production.yaml
vendored
31
.github/workflows/sync-to-production.yaml
vendored
@ -1,33 +1,16 @@
|
||||
# .github/workflows/sync-to-production.yaml
|
||||
name: Sync main to sandbox-production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: livekit-examples/sandbox-deploy-action@v1
|
||||
with:
|
||||
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
|
||||
production_branch: 'sandbox-production'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
32
.github/workflows/test.yaml
vendored
Normal file
32
.github/workflows/test.yaml
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
@ -1,4 +1,5 @@
|
||||
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';
|
||||
@ -6,6 +7,7 @@ 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) {
|
||||
@ -15,7 +17,10 @@ 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');
|
||||
const livekitServerUrl = region ? getLiveKitURL(region) : LIVEKIT_URL;
|
||||
if (!LIVEKIT_URL) {
|
||||
throw new Error('LIVEKIT_URL is not defined');
|
||||
}
|
||||
const livekitServerUrl = region ? getLiveKitURL(LIVEKIT_URL, region) : LIVEKIT_URL;
|
||||
let randomParticipantPostfix = request.cookies.get(COOKIE_KEY)?.value;
|
||||
if (livekitServerUrl === undefined) {
|
||||
throw new Error('Invalid region');
|
||||
@ -75,21 +80,6 @@ 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();
|
||||
|
||||
@ -11,24 +11,24 @@ import {
|
||||
type VideoCodec,
|
||||
} from 'livekit-client';
|
||||
import { DebugMode } from '@/lib/Debug';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { decodePassphrase } from '@/lib/client-utils';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 e2eePassphrase =
|
||||
typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
|
||||
const { worker, e2eePassphrase } = useSetupE2EE();
|
||||
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||
|
||||
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false);
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
publishDefaults: {
|
||||
@ -44,14 +44,12 @@ 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,
|
||||
@ -59,13 +57,29 @@ export function VideoConferenceClientImpl(props: {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="lk-room-container">
|
||||
|
||||
@ -7,9 +7,10 @@ export default async function CustomRoomConnection(props: {
|
||||
liveKitUrl?: string;
|
||||
token?: string;
|
||||
codec?: string;
|
||||
singlePC?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { liveKitUrl, token, codec } = await props.searchParams;
|
||||
const { liveKitUrl, token, codec, singlePC } = await props.searchParams;
|
||||
if (typeof liveKitUrl !== 'string') {
|
||||
return <h2>Missing LiveKit URL</h2>;
|
||||
}
|
||||
@ -22,7 +23,12 @@ export default async function CustomRoomConnection(props: {
|
||||
|
||||
return (
|
||||
<main data-lk-theme="default" style={{ height: '100%' }}>
|
||||
<VideoConferenceClientImpl liveKitUrl={liveKitUrl} token={token} codec={codec} />
|
||||
<VideoConferenceClientImpl
|
||||
liveKitUrl={liveKitUrl}
|
||||
token={token}
|
||||
codec={codec}
|
||||
singlePeerConnection={singlePC === 'true'}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import '../styles/globals.css';
|
||||
import '@livekit/components-styles';
|
||||
import '@livekit/components-styles/prefabs';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Providers } from '@/lib/Providers';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@ -52,7 +52,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en">
|
||||
<body data-lk-theme="default">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -23,8 +23,12 @@ 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';
|
||||
@ -93,15 +97,10 @@ function VideoConferenceComponent(props: {
|
||||
codec: VideoCodec;
|
||||
};
|
||||
}) {
|
||||
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 { worker, e2eePassphrase } = useSetupE2EE();
|
||||
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||
|
||||
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
|
||||
|
||||
const roomOptions = React.useMemo((): RoomOptions => {
|
||||
@ -109,30 +108,28 @@ 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: {
|
||||
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,
|
||||
},
|
||||
videoCaptureDefaults: videoCaptureDefaults,
|
||||
publishDefaults: publishDefaults,
|
||||
audioCaptureDefaults: {
|
||||
deviceId: props.userChoices.audioDeviceId ?? undefined,
|
||||
},
|
||||
adaptiveStream: { pixelDensity: 'screen' },
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
e2ee: e2eeEnabled
|
||||
? {
|
||||
keyProvider,
|
||||
worker,
|
||||
}
|
||||
: undefined,
|
||||
e2ee: keyProvider && worker && e2eeEnabled ? { keyProvider, worker } : undefined,
|
||||
singlePeerConnection: true,
|
||||
};
|
||||
}, [props.userChoices, props.options.hq, props.options.codec]);
|
||||
|
||||
@ -170,6 +167,7 @@ function VideoConferenceComponent(props: {
|
||||
room.on(RoomEvent.Disconnected, handleOnLeave);
|
||||
room.on(RoomEvent.EncryptionError, handleEncryptionError);
|
||||
room.on(RoomEvent.MediaDevicesError, handleError);
|
||||
|
||||
if (e2eeSetupComplete) {
|
||||
room
|
||||
.connect(
|
||||
@ -198,6 +196,8 @@ 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,6 +211,12 @@ function VideoConferenceComponent(props: {
|
||||
);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lowPowerMode) {
|
||||
console.warn('Low power mode enabled');
|
||||
}
|
||||
}, [lowPowerMode]);
|
||||
|
||||
return (
|
||||
<div className="lk-room-container">
|
||||
<RoomContext.Provider value={room}>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
MediaDeviceMenu,
|
||||
TrackReference,
|
||||
TrackToggle,
|
||||
useLocalParticipant,
|
||||
VideoTrack,
|
||||
@ -33,8 +34,10 @@ export function CameraSettings() {
|
||||
null,
|
||||
);
|
||||
|
||||
const camTrackRef = React.useMemo(() => {
|
||||
return { participant: localParticipant, publication: cameraTrack, source: Track.Source.Camera };
|
||||
const camTrackRef: TrackReference | undefined = React.useMemo(() => {
|
||||
return cameraTrack
|
||||
? { participant: localParticipant, publication: cameraTrack, source: Track.Source.Camera }
|
||||
: undefined;
|
||||
}, [localParticipant, cameraTrack]);
|
||||
|
||||
const selectBackground = (type: BackgroundType, imagePath?: string) => {
|
||||
@ -60,15 +63,17 @@ export function CameraSettings() {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<VideoTrack
|
||||
style={{
|
||||
maxHeight: '280px',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'right',
|
||||
transform: 'scaleX(-1)',
|
||||
}}
|
||||
trackRef={camTrackRef}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@ -1,109 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Track } from 'livekit-client';
|
||||
import { useLocalParticipant, useTrackToggle } from '@livekit/components-react';
|
||||
import { useSettingsState } from './SettingsContext';
|
||||
import { KeyCommand } from './types';
|
||||
import { useTrackToggle } from '@livekit/components-react';
|
||||
|
||||
export function KeyboardShortcuts() {
|
||||
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);
|
||||
const { toggle: toggleMic } = useTrackToggle({ source: Track.Source.Microphone });
|
||||
const { toggle: toggleCamera } = useTrackToggle({ source: Track.Source.Camera });
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlers = Object.entries(state.keybindings)
|
||||
.flatMap(([command, binding]) => {
|
||||
switch (command) {
|
||||
case KeyCommand.PTT:
|
||||
if (!state.enablePTT || !Array.isArray(binding)) return [];
|
||||
function handleShortcut(event: KeyboardEvent) {
|
||||
// Toggle microphone: Cmd/Ctrl-Shift-A
|
||||
if (toggleMic && event.key === 'A' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
toggleMic();
|
||||
}
|
||||
|
||||
const [enable, disable] = binding;
|
||||
const t = getEventTarget(enable.target);
|
||||
if (!t) return null;
|
||||
// Toggle camera: Cmd/Ctrl-Shift-V
|
||||
if (event.key === 'V' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
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]);
|
||||
window.addEventListener('keydown', handleShortcut);
|
||||
return () => window.removeEventListener('keydown', handleShortcut);
|
||||
}, [toggleMic, toggleCamera]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -3,14 +3,27 @@ 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();
|
||||
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',
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// enable Krisp by default
|
||||
setNoiseFilterEnabled(true);
|
||||
// enable Krisp by default on non-low power devices
|
||||
setNoiseFilterEnabled(!isLowPowerDevice());
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { SettingsStateProvider } from './SettingsContext';
|
||||
|
||||
export function Providers({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<SettingsStateProvider>
|
||||
<Toaster />
|
||||
{children}
|
||||
</SettingsStateProvider>
|
||||
);
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
'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 };
|
||||
@ -1,18 +1,16 @@
|
||||
'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
|
||||
*/
|
||||
@ -22,7 +20,6 @@ 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;
|
||||
@ -31,11 +28,7 @@ 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(
|
||||
@ -80,16 +73,6 @@ 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}>
|
||||
@ -102,7 +85,10 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
aria-pressed={tab === activeTab}
|
||||
>
|
||||
{settings[tab].label}
|
||||
{
|
||||
// @ts-ignore
|
||||
settings[tab].label
|
||||
}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
@ -154,36 +140,6 @@ 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
|
||||
|
||||
@ -19,3 +19,11 @@ 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';
|
||||
}
|
||||
|
||||
35
lib/getLiveKitURL.test.ts
Normal file
35
lib/getLiveKitURL.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
12
lib/getLiveKitURL.ts
Normal file
12
lib/getLiveKitURL.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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();
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,74 +0,0 @@
|
||||
'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];
|
||||
}
|
||||
31
lib/types.ts
31
lib/types.ts
@ -1,6 +1,5 @@
|
||||
import { LocalAudioTrack, LocalVideoTrack, videoCodecs } from 'livekit-client';
|
||||
import { VideoCodec } from 'livekit-client';
|
||||
import { Dispatch } from 'react';
|
||||
|
||||
export interface SessionProps {
|
||||
roomName: string;
|
||||
@ -27,33 +26,3 @@ 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>;
|
||||
};
|
||||
|
||||
71
lib/usePerfomanceOptimiser.ts
Normal file
71
lib/usePerfomanceOptimiser.ts
Normal file
@ -0,0 +1,71 @@
|
||||
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;
|
||||
}
|
||||
15
lib/useSetupE2EE.ts
Normal file
15
lib/useSetupE2EE.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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 };
|
||||
}
|
||||
@ -15,6 +15,23 @@ 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;
|
||||
|
||||
29
package.json
29
package.json
@ -8,34 +8,37 @@
|
||||
"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.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",
|
||||
"@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",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"tinykeys": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.15.19",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"prettier": "3.7.3",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.9"
|
||||
"packageManager": "pnpm@10.18.2"
|
||||
}
|
||||
|
||||
1539
pnpm-lock.yaml
generated
1539
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -15,10 +15,3 @@
|
||||
.tabs > .tab[aria-pressed='true'] {
|
||||
border-color: var(--lk-accent-bg);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user