Compare commits
153 Commits
test-pages
...
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 | ||
|
|
e418eeaac4 | ||
|
|
489ee7896b | ||
|
|
8a9a5a0aef | ||
|
|
71f62858b9 | ||
|
|
851079eaf0 | ||
|
|
2880d3685d | ||
|
|
fcd941d859 | ||
|
|
6b6e7c7ee7 | ||
|
|
edcb266dc4 | ||
|
|
680633c33c | ||
|
|
efac802d7b | ||
|
|
8b2ee6c324 | ||
|
|
7396247483 | ||
|
|
ffef3846b8 | ||
|
|
52d6f6a416 | ||
|
|
17ff1d6092 | ||
|
|
fca845d3a4 | ||
|
|
38b3951e7f | ||
|
|
edb22f66d1 | ||
|
|
4619ccc038 | ||
|
|
f5355b911f | ||
|
|
4a3c54d84a | ||
|
|
6753a96b91 | ||
|
|
0a7d440a54 | ||
|
|
c32ba3ae87 | ||
|
|
19115417db | ||
|
|
a47447c592 | ||
|
|
b97de89158 | ||
|
|
2d99e7703e | ||
|
|
63747dad36 | ||
|
|
210595ad93 | ||
|
|
10a0ac4a35 | ||
|
|
686b749282 | ||
|
|
f4ce8f53c3 | ||
|
|
aef4ba5e91 | ||
|
|
96cac3c5ec | ||
|
|
f07bef61cf | ||
|
|
09a45809a9 | ||
|
|
84ee6ac6b0 | ||
|
|
18ef3c70f1 | ||
|
|
53d309b74f | ||
|
|
23d8fbf5b0 | ||
|
|
fb4a8b0e11 | ||
|
|
f981960a08 | ||
|
|
7c4597d865 | ||
|
|
b3548ad265 | ||
|
|
bb949d2b73 | ||
|
|
5bf838a880 | ||
|
|
6bea1c4852 | ||
|
|
294a478d7a | ||
|
|
436f4ebefc | ||
|
|
1f08a442bb | ||
|
|
227378884e | ||
|
|
6e8081abba | ||
|
|
0df1f08871 | ||
|
|
ae5352331f | ||
|
|
86038b2e0d | ||
|
|
4c209e7cfa | ||
|
|
ffbbea062b | ||
|
|
da88946c11 | ||
|
|
0bc4c00627 | ||
|
|
8ddaec6cec | ||
|
|
45dfb8e2e5 | ||
|
|
b0b0a1db32 | ||
|
|
c1cbbd943d | ||
|
|
75b144497c | ||
|
|
50fe5d6208 | ||
|
|
37e190bc99 | ||
|
|
737b40b059 | ||
|
|
7964ba6778 | ||
|
|
398b172400 | ||
|
|
dbab065056 | ||
|
|
4ff13a70c4 | ||
|
|
bf86c02e59 | ||
|
|
c24b42eaad | ||
|
|
8e7c8adab8 | ||
|
|
e9b103be4b | ||
|
|
e0001e4100 | ||
|
|
2c752affcb | ||
|
|
a3b3ad5aee | ||
|
|
889c5bfa89 | ||
|
|
6fd2550f00 | ||
|
|
64bc61e2fe | ||
|
|
0aa40d775c | ||
|
|
aa9ce71ae8 | ||
|
|
dad25ad9b0 | ||
|
|
e5fca2c816 | ||
|
|
ff50608d10 | ||
|
|
4f9295c8a6 | ||
|
|
fccb386450 | ||
|
|
5bd6caaf01 | ||
|
|
55b029c865 | ||
|
|
c35dea2f97 | ||
|
|
d09ef1f163 | ||
|
|
c95828b4ca | ||
|
|
44d22674a4 | ||
|
|
9f7432c93f | ||
|
|
4d62818aea | ||
|
|
0578ad9ea1 | ||
|
|
6ba2656d87 | ||
|
|
9f44291bc9 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
public/background-images/*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
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 |
31
.github/assets/template-graphic.svg
vendored
Normal file
31
.github/assets/template-graphic.svg
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<svg width="270" height="151" viewBox="0 0 270 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="270" height="151" fill="#070707"/>
|
||||||
|
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" fill="#131313"/>
|
||||||
|
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" stroke="#1F1F1F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M101.167 71.4998C101.167 69.6589 102.66 68.1665 104.501 68.1665C106.342 68.1665 107.834 69.6589 107.834 71.4998C107.834 73.3408 106.342 74.8332 104.501 74.8332C102.66 74.8332 101.167 73.3408 101.167 71.4998Z" fill="#666666"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.834 82.1665C97.834 78.4846 100.819 75.4998 104.501 75.4998C108.183 75.4998 111.167 78.4846 111.167 82.1665V82.8332H97.834V82.1665Z" fill="#666666"/>
|
||||||
|
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" fill="#131313"/>
|
||||||
|
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
|
||||||
|
<g clip-path="url(#clip0_834_19648)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 23.8333C232.167 21.9924 233.66 20.5 235.501 20.5C237.342 20.5 238.834 21.9924 238.834 23.8333C238.834 25.6743 237.342 27.1667 235.501 27.1667C233.66 27.1667 232.167 25.6743 232.167 23.8333Z" fill="#666666"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 34.5C228.834 30.8181 231.819 27.8333 235.501 27.8333C239.183 27.8333 242.167 30.8181 242.167 34.5V35.1667H228.834V34.5Z" fill="#666666"/>
|
||||||
|
</g>
|
||||||
|
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" fill="#131313"/>
|
||||||
|
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" stroke="#CCCCCC"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 71.4998C232.167 69.6589 233.66 68.1665 235.501 68.1665C237.342 68.1665 238.834 69.6589 238.834 71.4998C238.834 73.3408 237.342 74.8332 235.501 74.8332C233.66 74.8332 232.167 73.3408 232.167 71.4998Z" fill="#CCCCCC"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 82.1665C228.834 78.4846 231.819 75.4998 235.501 75.4998C239.183 75.4998 242.167 78.4846 242.167 82.1665V82.8332H228.834V82.1665Z" fill="#CCCCCC"/>
|
||||||
|
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" fill="#131313"/>
|
||||||
|
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
|
||||||
|
<g clip-path="url(#clip1_834_19648)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 119.167C232.167 117.326 233.66 115.833 235.501 115.833C237.342 115.833 238.834 117.326 238.834 119.167C238.834 121.008 237.342 122.5 235.501 122.5C233.66 122.5 232.167 121.008 232.167 119.167Z" fill="#666666"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 129.833C228.834 126.152 231.819 123.167 235.501 123.167C239.183 123.167 242.167 126.152 242.167 129.833V130.5H228.834V129.833Z" fill="#666666"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_834_19648">
|
||||||
|
<rect width="16" height="16" fill="white" transform="translate(227.5 19.8335)"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip1_834_19648">
|
||||||
|
<rect width="16" height="16" fill="white" transform="translate(227.5 115.167)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
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 |
16
.github/workflows/sync-to-production.yaml
vendored
Normal file
16
.github/workflows/sync-to-production.yaml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# .github/workflows/sync-to-production.yaml
|
||||||
|
name: Sync main to sandbox-production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: livekit-examples/sandbox-deploy-action@v1
|
||||||
|
with:
|
||||||
|
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
|
||||||
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
LiveKit Meet is an open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://livekit.io/cloud), and Next.js. It's been completely redesigned from the ground up using our new components library.
|
LiveKit Meet is an open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://cloud.livekit.io/), and Next.js. It's been completely redesigned from the ground up using our new components library.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
import { randomString } from '@/lib/client-utils';
|
||||||
|
import { getLiveKitURL } from '@/lib/getLiveKitURL';
|
||||||
import { ConnectionDetails } from '@/lib/types';
|
import { ConnectionDetails } from '@/lib/types';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
|
import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
@ -7,6 +8,8 @@ const API_KEY = process.env.LIVEKIT_API_KEY;
|
|||||||
const API_SECRET = process.env.LIVEKIT_API_SECRET;
|
const API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||||
const LIVEKIT_URL = process.env.LIVEKIT_URL;
|
const LIVEKIT_URL = process.env.LIVEKIT_URL;
|
||||||
|
|
||||||
|
const COOKIE_KEY = 'random-participant-postfix';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
@ -14,7 +17,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const participantName = request.nextUrl.searchParams.get('participantName');
|
const participantName = request.nextUrl.searchParams.get('participantName');
|
||||||
const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
|
const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
|
||||||
const region = request.nextUrl.searchParams.get('region');
|
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) {
|
if (livekitServerUrl === undefined) {
|
||||||
throw new Error('Invalid region');
|
throw new Error('Invalid region');
|
||||||
}
|
}
|
||||||
@ -27,9 +34,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate participant token
|
// Generate participant token
|
||||||
|
if (!randomParticipantPostfix) {
|
||||||
|
randomParticipantPostfix = randomString(4);
|
||||||
|
}
|
||||||
const participantToken = await createParticipantToken(
|
const participantToken = await createParticipantToken(
|
||||||
{
|
{
|
||||||
identity: `${participantName}__${randomUUID()}`,
|
identity: `${participantName}__${randomParticipantPostfix}`,
|
||||||
name: participantName,
|
name: participantName,
|
||||||
metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
@ -43,7 +53,12 @@ export async function GET(request: NextRequest) {
|
|||||||
participantToken: participantToken,
|
participantToken: participantToken,
|
||||||
participantName: participantName,
|
participantName: participantName,
|
||||||
};
|
};
|
||||||
return NextResponse.json(data);
|
return new NextResponse(JSON.stringify(data), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': `${COOKIE_KEY}=${randomParticipantPostfix}; Path=/; HttpOnly; SameSite=Strict; Secure; Expires=${getCookieExpirationTime()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return new NextResponse(error.message, { status: 500 });
|
return new NextResponse(error.message, { status: 500 });
|
||||||
@ -65,17 +80,10 @@ function createParticipantToken(userInfo: AccessTokenOptions, roomName: string)
|
|||||||
return at.toJwt();
|
return at.toJwt();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getCookieExpirationTime(): string {
|
||||||
* Get the LiveKit server URL for the given region.
|
var now = new Date();
|
||||||
*/
|
var time = now.getTime();
|
||||||
function getLiveKitURL(region: string | null): string {
|
var expireTime = time + 60 * 120 * 1000;
|
||||||
let targetKey = 'LIVEKIT_URL';
|
now.setTime(expireTime);
|
||||||
if (region) {
|
return now.toUTCString();
|
||||||
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
|
|
||||||
}
|
|
||||||
const url = process.env[targetKey];
|
|
||||||
if (!url) {
|
|
||||||
throw new Error(`${targetKey} is not defined`);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { formatChatMessageLinks, LiveKitRoom, VideoConference } from '@livekit/components-react';
|
import { formatChatMessageLinks, RoomContext, VideoConference } from '@livekit/components-react';
|
||||||
import {
|
import {
|
||||||
ExternalE2EEKeyProvider,
|
ExternalE2EEKeyProvider,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
@ -11,23 +11,24 @@ import {
|
|||||||
type VideoCodec,
|
type VideoCodec,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { DebugMode } from '@/lib/Debug';
|
import { DebugMode } from '@/lib/Debug';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { decodePassphrase } from '@/lib/client-utils';
|
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
|
||||||
import { SettingsMenu } from '@/lib/SettingsMenu';
|
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||||
|
import { useSetupE2EE } from '@/lib/useSetupE2EE';
|
||||||
|
import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
|
||||||
|
|
||||||
export function VideoConferenceClientImpl(props: {
|
export function VideoConferenceClientImpl(props: {
|
||||||
liveKitUrl: string;
|
liveKitUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
codec: VideoCodec | undefined;
|
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 keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
const { worker, e2eePassphrase } = useSetupE2EE();
|
||||||
const e2eePassphrase =
|
|
||||||
typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
|
|
||||||
const e2eeEnabled = !!(e2eePassphrase && worker);
|
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||||
|
|
||||||
|
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false);
|
||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
return {
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
@ -43,36 +44,55 @@ export function VideoConferenceClientImpl(props: {
|
|||||||
worker,
|
worker,
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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 => {
|
const connectOptions = useMemo((): RoomConnectOptions => {
|
||||||
return {
|
return {
|
||||||
autoSubscribe: true,
|
autoSubscribe: true,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LiveKitRoom
|
<div className="lk-room-container">
|
||||||
room={room}
|
<RoomContext.Provider value={room}>
|
||||||
token={props.token}
|
<KeyboardShortcuts />
|
||||||
connectOptions={connectOptions}
|
<VideoConference
|
||||||
serverUrl={props.liveKitUrl}
|
chatMessageFormatter={formatChatMessageLinks}
|
||||||
audio={true}
|
SettingsComponent={
|
||||||
video={true}
|
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
|
||||||
>
|
}
|
||||||
<VideoConference
|
/>
|
||||||
chatMessageFormatter={formatChatMessageLinks}
|
<DebugMode logLevel={LogLevel.debug} />
|
||||||
SettingsComponent={
|
</RoomContext.Provider>
|
||||||
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
|
</div>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DebugMode logLevel={LogLevel.debug} />
|
|
||||||
</LiveKitRoom>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,15 @@ import { videoCodecs } from 'livekit-client';
|
|||||||
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
|
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
|
||||||
import { isVideoCodec } from '@/lib/types';
|
import { isVideoCodec } from '@/lib/types';
|
||||||
|
|
||||||
export default function CustomRoomConnection(props: {
|
export default async function CustomRoomConnection(props: {
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
liveKitUrl?: string;
|
liveKitUrl?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
codec?: string;
|
codec?: string;
|
||||||
};
|
singlePC?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { liveKitUrl, token, codec } = props.searchParams;
|
const { liveKitUrl, token, codec, singlePC } = await props.searchParams;
|
||||||
if (typeof liveKitUrl !== 'string') {
|
if (typeof liveKitUrl !== 'string') {
|
||||||
return <h2>Missing LiveKit URL</h2>;
|
return <h2>Missing LiveKit URL</h2>;
|
||||||
}
|
}
|
||||||
@ -21,8 +22,13 @@ export default function CustomRoomConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-lk-theme="default">
|
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import '../styles/globals.css';
|
|||||||
import '@livekit/components-styles';
|
import '@livekit/components-styles';
|
||||||
import '@livekit/components-styles/prefabs';
|
import '@livekit/components-styles/prefabs';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@ -50,7 +51,10 @@ export const viewport: Viewport = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body data-lk-theme="default">
|
||||||
|
<Toaster />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import { decodePassphrase } from '@/lib/client-utils';
|
import { decodePassphrase } from '@/lib/client-utils';
|
||||||
import { DebugMode } from '@/lib/Debug';
|
import { DebugMode } from '@/lib/Debug';
|
||||||
|
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
|
||||||
import { RecordingIndicator } from '@/lib/RecordingIndicator';
|
import { RecordingIndicator } from '@/lib/RecordingIndicator';
|
||||||
import { SettingsMenu } from '@/lib/SettingsMenu';
|
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||||
import { ConnectionDetails } from '@/lib/types';
|
import { ConnectionDetails } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatChatMessageLinks,
|
formatChatMessageLinks,
|
||||||
LiveKitRoom,
|
|
||||||
LocalUserChoices,
|
LocalUserChoices,
|
||||||
PreJoin,
|
PreJoin,
|
||||||
|
RoomContext,
|
||||||
VideoConference,
|
VideoConference,
|
||||||
} from '@livekit/components-react';
|
} from '@livekit/components-react';
|
||||||
import {
|
import {
|
||||||
@ -20,9 +22,13 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
DeviceUnsupportedError,
|
DeviceUnsupportedError,
|
||||||
RoomConnectOptions,
|
RoomConnectOptions,
|
||||||
|
RoomEvent,
|
||||||
|
TrackPublishDefaults,
|
||||||
|
VideoCaptureOptions,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import { useSetupE2EE } from '@/lib/useSetupE2EE';
|
||||||
|
import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
|
||||||
|
|
||||||
const CONN_DETAILS_ENDPOINT =
|
const CONN_DETAILS_ENDPOINT =
|
||||||
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
|
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
|
||||||
@ -50,8 +56,7 @@ export function PageClientImpl(props: {
|
|||||||
|
|
||||||
const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => {
|
const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => {
|
||||||
setPreJoinChoices(values);
|
setPreJoinChoices(values);
|
||||||
const url = new URL('/', window.location.origin);
|
const url = new URL(CONN_DETAILS_ENDPOINT, window.location.origin);
|
||||||
url.pathname = CONN_DETAILS_ENDPOINT;
|
|
||||||
url.searchParams.append('roomName', props.roomName);
|
url.searchParams.append('roomName', props.roomName);
|
||||||
url.searchParams.append('participantName', values.username);
|
url.searchParams.append('participantName', values.username);
|
||||||
if (props.region) {
|
if (props.region) {
|
||||||
@ -64,7 +69,7 @@ export function PageClientImpl(props: {
|
|||||||
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
|
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-lk-theme="default">
|
<main data-lk-theme="default" style={{ height: '100%' }}>
|
||||||
{connectionDetails === undefined || preJoinChoices === undefined ? (
|
{connectionDetails === undefined || preJoinChoices === undefined ? (
|
||||||
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
||||||
<PreJoin
|
<PreJoin
|
||||||
@ -92,90 +97,137 @@ function VideoConferenceComponent(props: {
|
|||||||
codec: VideoCodec;
|
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 keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
const { worker, e2eePassphrase } = useSetupE2EE();
|
||||||
|
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||||
|
|
||||||
|
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
|
||||||
|
|
||||||
const roomOptions = React.useMemo((): RoomOptions => {
|
const roomOptions = React.useMemo((): RoomOptions => {
|
||||||
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
|
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
|
||||||
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
|
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
|
||||||
videoCodec = undefined;
|
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 {
|
return {
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: videoCaptureDefaults,
|
||||||
deviceId: props.userChoices.videoDeviceId ?? undefined,
|
publishDefaults: publishDefaults,
|
||||||
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: {
|
audioCaptureDefaults: {
|
||||||
deviceId: props.userChoices.audioDeviceId ?? undefined,
|
deviceId: props.userChoices.audioDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
adaptiveStream: { pixelDensity: 'screen' },
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
e2ee: e2eeEnabled
|
e2ee: keyProvider && worker && e2eeEnabled ? { keyProvider, worker } : undefined,
|
||||||
? {
|
singlePeerConnection: true,
|
||||||
keyProvider,
|
|
||||||
worker,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
// @ts-ignore
|
|
||||||
setLogLevel('debug', 'lk-e2ee');
|
|
||||||
}, [props.userChoices, props.options.hq, props.options.codec]);
|
}, [props.userChoices, props.options.hq, props.options.codec]);
|
||||||
|
|
||||||
const room = React.useMemo(() => new Room(roomOptions), []);
|
const room = React.useMemo(() => new Room(roomOptions), []);
|
||||||
|
|
||||||
if (e2eeEnabled) {
|
React.useEffect(() => {
|
||||||
keyProvider.setKey(decodePassphrase(e2eePassphrase));
|
if (e2eeEnabled) {
|
||||||
room.setE2EEEnabled(true).catch((e) => {
|
keyProvider
|
||||||
if (e instanceof DeviceUnsupportedError) {
|
.setKey(decodePassphrase(e2eePassphrase))
|
||||||
alert(
|
.then(() => {
|
||||||
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
|
room.setE2EEEnabled(true).catch((e) => {
|
||||||
);
|
if (e instanceof DeviceUnsupportedError) {
|
||||||
console.error(e);
|
alert(
|
||||||
}
|
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
|
||||||
});
|
);
|
||||||
}
|
console.error(e);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => setE2eeSetupComplete(true));
|
||||||
|
} else {
|
||||||
|
setE2eeSetupComplete(true);
|
||||||
|
}
|
||||||
|
}, [e2eeEnabled, room, e2eePassphrase]);
|
||||||
|
|
||||||
const connectOptions = React.useMemo((): RoomConnectOptions => {
|
const connectOptions = React.useMemo((): RoomConnectOptions => {
|
||||||
return {
|
return {
|
||||||
autoSubscribe: true,
|
autoSubscribe: true,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
room.on(RoomEvent.Disconnected, handleOnLeave);
|
||||||
|
room.on(RoomEvent.EncryptionError, handleEncryptionError);
|
||||||
|
room.on(RoomEvent.MediaDevicesError, handleError);
|
||||||
|
|
||||||
|
if (e2eeSetupComplete) {
|
||||||
|
room
|
||||||
|
.connect(
|
||||||
|
props.connectionDetails.serverUrl,
|
||||||
|
props.connectionDetails.participantToken,
|
||||||
|
connectOptions,
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
handleError(error);
|
||||||
|
});
|
||||||
|
if (props.userChoices.videoEnabled) {
|
||||||
|
room.localParticipant.setCameraEnabled(true).catch((error) => {
|
||||||
|
handleError(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.userChoices.audioEnabled) {
|
||||||
|
room.localParticipant.setMicrophoneEnabled(true).catch((error) => {
|
||||||
|
handleError(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Disconnected, handleOnLeave);
|
||||||
|
room.off(RoomEvent.EncryptionError, handleEncryptionError);
|
||||||
|
room.off(RoomEvent.MediaDevicesError, handleError);
|
||||||
|
};
|
||||||
|
}, [e2eeSetupComplete, room, props.connectionDetails, props.userChoices]);
|
||||||
|
|
||||||
|
const lowPowerMode = useLowCPUOptimizer(room);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
|
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
|
||||||
|
const handleError = React.useCallback((error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
alert(`Encountered an unexpected error, check the console logs for details: ${error.message}`);
|
||||||
|
}, []);
|
||||||
|
const handleEncryptionError = React.useCallback((error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
alert(
|
||||||
|
`Encountered an unexpected encryption error, check the console logs for details: ${error.message}`,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lowPowerMode) {
|
||||||
|
console.warn('Low power mode enabled');
|
||||||
|
}
|
||||||
|
}, [lowPowerMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="lk-room-container">
|
||||||
<LiveKitRoom
|
<RoomContext.Provider value={room}>
|
||||||
room={room}
|
<KeyboardShortcuts />
|
||||||
token={props.connectionDetails.participantToken}
|
|
||||||
serverUrl={props.connectionDetails.serverUrl}
|
|
||||||
connectOptions={connectOptions}
|
|
||||||
video={props.userChoices.videoEnabled}
|
|
||||||
audio={props.userChoices.audioEnabled}
|
|
||||||
onDisconnected={handleOnLeave}
|
|
||||||
>
|
|
||||||
<VideoConference
|
<VideoConference
|
||||||
chatMessageFormatter={formatChatMessageLinks}
|
chatMessageFormatter={formatChatMessageLinks}
|
||||||
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
|
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
|
||||||
/>
|
/>
|
||||||
<DebugMode />
|
<DebugMode />
|
||||||
<RecordingIndicator />
|
<RecordingIndicator />
|
||||||
</LiveKitRoom>
|
</RoomContext.Provider>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,25 +2,32 @@ import * as React from 'react';
|
|||||||
import { PageClientImpl } from './PageClientImpl';
|
import { PageClientImpl } from './PageClientImpl';
|
||||||
import { isVideoCodec } from '@/lib/types';
|
import { isVideoCodec } from '@/lib/types';
|
||||||
|
|
||||||
export default function Page({
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: { roomName: string };
|
params: Promise<{ roomName: string }>;
|
||||||
searchParams: {
|
searchParams: Promise<{
|
||||||
// FIXME: We should not allow values for regions if in playground mode.
|
// FIXME: We should not allow values for regions if in playground mode.
|
||||||
region?: string;
|
region?: string;
|
||||||
hq?: string;
|
hq?: string;
|
||||||
codec?: string;
|
codec?: string;
|
||||||
};
|
}>;
|
||||||
}) {
|
}) {
|
||||||
|
const _params = await params;
|
||||||
|
const _searchParams = await searchParams;
|
||||||
const codec =
|
const codec =
|
||||||
typeof searchParams.codec === 'string' && isVideoCodec(searchParams.codec)
|
typeof _searchParams.codec === 'string' && isVideoCodec(_searchParams.codec)
|
||||||
? searchParams.codec
|
? _searchParams.codec
|
||||||
: 'vp9';
|
: 'vp9';
|
||||||
const hq = searchParams.hq === 'true' ? true : false;
|
const hq = _searchParams.hq === 'true' ? true : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageClientImpl roomName={params.roomName} region={searchParams.region} hq={hq} codec={codec} />
|
<PageClientImpl
|
||||||
|
roomName={_params.roomName}
|
||||||
|
region={_searchParams.region}
|
||||||
|
hq={hq}
|
||||||
|
codec={codec}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
175
lib/CameraSettings.tsx
Normal file
175
lib/CameraSettings.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
MediaDeviceMenu,
|
||||||
|
TrackReference,
|
||||||
|
TrackToggle,
|
||||||
|
useLocalParticipant,
|
||||||
|
VideoTrack,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { BackgroundBlur, VirtualBackground } from '@livekit/track-processors';
|
||||||
|
import { isLocalTrack, LocalTrackPublication, Track } from 'livekit-client';
|
||||||
|
import Desk from '../public/background-images/samantha-gades-BlIhVfXbi9s-unsplash.jpg';
|
||||||
|
import Nature from '../public/background-images/ali-kazal-tbw_KQE3Cbg-unsplash.jpg';
|
||||||
|
|
||||||
|
// Background image paths
|
||||||
|
const BACKGROUND_IMAGES = [
|
||||||
|
{ name: 'Desk', path: Desk },
|
||||||
|
{ name: 'Nature', path: Nature },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Background options
|
||||||
|
type BackgroundType = 'none' | 'blur' | 'image';
|
||||||
|
|
||||||
|
export function CameraSettings() {
|
||||||
|
const { cameraTrack, localParticipant } = useLocalParticipant();
|
||||||
|
const [backgroundType, setBackgroundType] = React.useState<BackgroundType>(
|
||||||
|
(cameraTrack as LocalTrackPublication)?.track?.getProcessor()?.name === 'background-blur'
|
||||||
|
? 'blur'
|
||||||
|
: (cameraTrack as LocalTrackPublication)?.track?.getProcessor()?.name === 'virtual-background'
|
||||||
|
? 'image'
|
||||||
|
: 'none',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [virtualBackgroundImagePath, setVirtualBackgroundImagePath] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
setBackgroundType(type);
|
||||||
|
if (type === 'image' && imagePath) {
|
||||||
|
setVirtualBackgroundImagePath(imagePath);
|
||||||
|
} else if (type !== 'image') {
|
||||||
|
setVirtualBackgroundImagePath(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLocalTrack(cameraTrack?.track)) {
|
||||||
|
if (backgroundType === 'blur') {
|
||||||
|
cameraTrack.track?.setProcessor(BackgroundBlur());
|
||||||
|
} else if (backgroundType === 'image' && virtualBackgroundImagePath) {
|
||||||
|
cameraTrack.track?.setProcessor(VirtualBackground(virtualBackgroundImagePath));
|
||||||
|
} else {
|
||||||
|
cameraTrack.track?.stopProcessor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cameraTrack, backgroundType, virtualBackgroundImagePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{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>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu kind="videoinput" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<div style={{ marginBottom: '8px' }}>Background Effects</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => selectBackground('none')}
|
||||||
|
className="lk-button"
|
||||||
|
aria-pressed={backgroundType === 'none'}
|
||||||
|
style={{
|
||||||
|
border: backgroundType === 'none' ? '2px solid #0090ff' : '1px solid #d1d1d1',
|
||||||
|
minWidth: '80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => selectBackground('blur')}
|
||||||
|
className="lk-button"
|
||||||
|
aria-pressed={backgroundType === 'blur'}
|
||||||
|
style={{
|
||||||
|
border: backgroundType === 'blur' ? '2px solid #0090ff' : '1px solid #d1d1d1',
|
||||||
|
minWidth: '80px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '60px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
filter: 'blur(8px)',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Blur
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{BACKGROUND_IMAGES.map((image) => (
|
||||||
|
<button
|
||||||
|
key={image.path.src}
|
||||||
|
onClick={() => selectBackground('image', image.path.src)}
|
||||||
|
className="lk-button"
|
||||||
|
aria-pressed={
|
||||||
|
backgroundType === 'image' && virtualBackgroundImagePath === image.path.src
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${image.path.src})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: '80px',
|
||||||
|
height: '60px',
|
||||||
|
border:
|
||||||
|
backgroundType === 'image' && virtualBackgroundImagePath === image.path.src
|
||||||
|
? '2px solid #0090ff'
|
||||||
|
: '1px solid #d1d1d1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{image.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useRoomContext } from '@livekit/components-react';
|
import { useRoomContext } from '@livekit/components-react';
|
||||||
import { setLogLevel, LogLevel, RemoteTrackPublication, setLogExtension } from 'livekit-client';
|
import { setLogLevel, LogLevel, RemoteTrackPublication, setLogExtension } from 'livekit-client';
|
||||||
|
// @ts-ignore
|
||||||
import { tinykeys } from 'tinykeys';
|
import { tinykeys } from 'tinykeys';
|
||||||
import { datadogLogs } from '@datadog/browser-logs';
|
import { datadogLogs } from '@datadog/browser-logs';
|
||||||
|
|
||||||
|
|||||||
31
lib/KeyboardShortcuts.tsx
Normal file
31
lib/KeyboardShortcuts.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import { useTrackToggle } from '@livekit/components-react';
|
||||||
|
|
||||||
|
export function KeyboardShortcuts() {
|
||||||
|
const { toggle: toggleMic } = useTrackToggle({ source: Track.Source.Microphone });
|
||||||
|
const { toggle: toggleCamera } = useTrackToggle({ source: Track.Source.Camera });
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
55
lib/MicrophoneSettings.tsx
Normal file
55
lib/MicrophoneSettings.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// enable Krisp by default on non-low power devices
|
||||||
|
setNoiseFilterEnabled(!isLowPowerDevice());
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section className="lk-button-group">
|
||||||
|
<TrackToggle source={Track.Source.Microphone}>Microphone</TrackToggle>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu kind="audioinput" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="lk-button"
|
||||||
|
onClick={() => setNoiseFilterEnabled(!isNoiseFilterEnabled)}
|
||||||
|
disabled={isNoiseFilterPending}
|
||||||
|
aria-pressed={isNoiseFilterEnabled}
|
||||||
|
>
|
||||||
|
{isNoiseFilterEnabled ? 'Disable' : 'Enable'} Enhanced Noise Cancellation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useIsRecording } from '@livekit/components-react';
|
import { useIsRecording } from '@livekit/components-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export function RecordingIndicator() {
|
export function RecordingIndicator() {
|
||||||
const isRecording = useIsRecording();
|
const isRecording = useIsRecording();
|
||||||
@ -9,7 +10,16 @@ export function RecordingIndicator() {
|
|||||||
if (isRecording !== wasRecording) {
|
if (isRecording !== wasRecording) {
|
||||||
setWasRecording(isRecording);
|
setWasRecording(isRecording);
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
window.alert('This meeting is being recorded');
|
toast('This meeting is being recorded', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: '🎥',
|
||||||
|
position: 'top-center',
|
||||||
|
className: 'lk-button',
|
||||||
|
style: {
|
||||||
|
backgroundColor: 'var(--lk-danger3)',
|
||||||
|
color: 'var(--lk-fg)',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
@ -22,7 +32,7 @@ export function RecordingIndicator() {
|
|||||||
left: '0',
|
left: '0',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
boxShadow: isRecording ? 'red 0px 0px 0px 3px inset' : 'none',
|
boxShadow: isRecording ? 'var(--lk-danger3) 0px 0px 0px 3px inset' : 'none',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { LocalAudioTrack, Track } from 'livekit-client';
|
import { Track } from 'livekit-client';
|
||||||
import {
|
import {
|
||||||
useMaybeLayoutContext,
|
useMaybeLayoutContext,
|
||||||
useLocalParticipant,
|
|
||||||
MediaDeviceMenu,
|
MediaDeviceMenu,
|
||||||
TrackToggle,
|
TrackToggle,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
useIsRecording,
|
useIsRecording,
|
||||||
} from '@livekit/components-react';
|
} from '@livekit/components-react';
|
||||||
import styles from '../styles/SettingsMenu.module.css';
|
import styles from '../styles/SettingsMenu.module.css';
|
||||||
|
import { CameraSettings } from './CameraSettings';
|
||||||
|
import { MicrophoneSettings } from './MicrophoneSettings';
|
||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
@ -27,7 +27,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
const settings = React.useMemo(() => {
|
const settings = React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
|
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
|
||||||
effects: { label: 'Effects' },
|
|
||||||
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
|
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -36,34 +35,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
|
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
|
||||||
[settings],
|
[settings],
|
||||||
);
|
);
|
||||||
const { microphoneTrack } = useLocalParticipant();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState(tabs[0]);
|
const [activeTab, setActiveTab] = React.useState(tabs[0]);
|
||||||
const [isNoiseFilterEnabled, setIsNoiseFilterEnabled] = React.useState(true);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const micPublication = microphoneTrack;
|
|
||||||
if (micPublication && micPublication.track instanceof LocalAudioTrack) {
|
|
||||||
const currentProcessor = micPublication.track.getProcessor();
|
|
||||||
if (currentProcessor && !isNoiseFilterEnabled) {
|
|
||||||
micPublication.track.stopProcessor();
|
|
||||||
} else if (!currentProcessor && isNoiseFilterEnabled) {
|
|
||||||
import('@livekit/krisp-noise-filter')
|
|
||||||
.then(({ KrispNoiseFilter, isKrispNoiseFilterSupported }) => {
|
|
||||||
if (!isKrispNoiseFilterSupported()) {
|
|
||||||
console.error('Enhanced noise filter is not supported for this browser');
|
|
||||||
setIsNoiseFilterEnabled(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
micPublication?.track
|
|
||||||
// @ts-ignore
|
|
||||||
?.setProcessor(KrispNoiseFilter())
|
|
||||||
.then(() => console.log('successfully set noise filter'));
|
|
||||||
})
|
|
||||||
.catch((e) => console.error('Failed to load noise filter', e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isNoiseFilterEnabled, microphoneTrack]);
|
|
||||||
|
|
||||||
const isRecording = useIsRecording();
|
const isRecording = useIsRecording();
|
||||||
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
|
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
|
||||||
@ -102,7 +74,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-menu" style={{ width: '100%' }} {...props}>
|
<div className="settings-menu" style={{ width: '100%', position: 'relative' }} {...props}>
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{tabs.map(
|
{tabs.map(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
@ -127,22 +99,16 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
{settings.media && settings.media.camera && (
|
{settings.media && settings.media.camera && (
|
||||||
<>
|
<>
|
||||||
<h3>Camera</h3>
|
<h3>Camera</h3>
|
||||||
<section className="lk-button-group">
|
<section>
|
||||||
<TrackToggle source={Track.Source.Camera}>Camera</TrackToggle>
|
<CameraSettings />
|
||||||
<div className="lk-button-group-menu">
|
|
||||||
<MediaDeviceMenu kind="videoinput" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.media && settings.media.microphone && (
|
{settings.media && settings.media.microphone && (
|
||||||
<>
|
<>
|
||||||
<h3>Microphone</h3>
|
<h3>Microphone</h3>
|
||||||
<section className="lk-button-group">
|
<section>
|
||||||
<TrackToggle source={Track.Source.Microphone}>Microphone</TrackToggle>
|
<MicrophoneSettings />
|
||||||
<div className="lk-button-group-menu">
|
|
||||||
<MediaDeviceMenu kind="audioinput" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -159,20 +125,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'effects' && (
|
|
||||||
<>
|
|
||||||
<h3>Audio</h3>
|
|
||||||
<section>
|
|
||||||
<label htmlFor="noise-filter"> Enhanced Noise Cancellation</label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="noise-filter"
|
|
||||||
onChange={(ev) => setIsNoiseFilterEnabled(ev.target.checked)}
|
|
||||||
checked={isNoiseFilterEnabled}
|
|
||||||
></input>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeTab === 'recording' && (
|
{activeTab === 'recording' && (
|
||||||
<>
|
<>
|
||||||
<h3>Record Meeting</h3>
|
<h3>Record Meeting</h3>
|
||||||
@ -189,12 +141,14 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||||
className={`lk-button ${styles.settingsCloseButton}`}
|
<button
|
||||||
onClick={() => layoutContext?.widget.dispatch?.({ msg: 'toggle_settings' })}
|
className={`lk-button`}
|
||||||
>
|
onClick={() => layoutContext?.widget.dispatch?.({ msg: 'toggle_settings' })}
|
||||||
Close
|
>
|
||||||
</button>
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,3 +19,11 @@ export function randomString(length: number): string {
|
|||||||
}
|
}
|
||||||
return result;
|
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();
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
productionBrowserSourceMaps: true,
|
productionBrowserSourceMaps: true,
|
||||||
|
images: {
|
||||||
|
formats: ['image/webp'],
|
||||||
|
},
|
||||||
webpack: (config, { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }) => {
|
webpack: (config, { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }) => {
|
||||||
// Important: return the modified config
|
// Important: return the modified config
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
@ -9,8 +12,26 @@ const nextConfig = {
|
|||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
use: ['source-map-loader'],
|
use: ['source-map-loader'],
|
||||||
});
|
});
|
||||||
|
|
||||||
return config;
|
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;
|
module.exports = nextConfig;
|
||||||
|
|||||||
43
package.json
43
package.json
@ -6,34 +6,39 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"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": {
|
"dependencies": {
|
||||||
"@datadog/browser-logs": "^5.10.0",
|
"@datadog/browser-logs": "^5.23.3",
|
||||||
"@livekit/components-react": "2.4.3",
|
"@livekit/components-react": "2.9.19",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/krisp-noise-filter": "^0.2.5",
|
"@livekit/krisp-noise-filter": "0.4.1",
|
||||||
"livekit-client": "2.5.0",
|
"@livekit/track-processors": "^0.7.0",
|
||||||
"livekit-server-sdk": "2.6.1",
|
"livekit-client": "2.17.2",
|
||||||
"next": "14.2.5",
|
"livekit-server-sdk": "2.15.0",
|
||||||
"next-seo": "^6.0.0",
|
"next": "15.2.8",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"tinykeys": "^2.1.0"
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"tinykeys": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.14.13",
|
"@types/node": "24.10.13",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.27",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.7",
|
||||||
"eslint": "9.8.0",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "15.5.6",
|
||||||
|
"prettier": "3.7.3",
|
||||||
"source-map-loader": "^5.0.0",
|
"source-map-loader": "^5.0.0",
|
||||||
"typescript": "5.5.4"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"packageManager": "pnpm@10.18.2"
|
||||||
"overrides": {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
3889
pnpm-lock.yaml
generated
3889
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4a3c9eb8da1ef3ddf2439428b49c11abd9a765e056600bd4f1d89a5dfc82778a
|
||||||
|
size 52339
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8bc017736e04acb0188f69cec0aafb88bd6891bfc4a6ff1530665e8dc210dbdf
|
||||||
|
size 1273171
|
||||||
@ -1,11 +1,12 @@
|
|||||||
.main {
|
.main {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
padding-bottom: 100px;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabContainer {
|
.tabContainer {
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
.settingsCloseButton {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--lk-grid-gap);
|
|
||||||
bottom: var(--lk-grid-gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -10,17 +10,16 @@ html {
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#__next,
|
|
||||||
main {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
padding-inline: 2rem;
|
padding-inline: 2rem;
|
||||||
@ -43,8 +42,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "ES2020"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "ES2020",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user