Compare commits

..

1 Commits

Author SHA1 Message Date
lukasIO
0c362d8b1b update lk client 2024-02-09 16:00:40 +01:00
46 changed files with 2756 additions and 5509 deletions

View File

@ -1,30 +1,12 @@
# 1. Copy this file and rename it to .env.local
# 2. Update the enviroment variables below.
# REQUIRED SETTINGS
# #################
# If you are using LiveKit Cloud, the API key and secret can be generated from the Cloud Dashboard.
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# URL pointing to the LiveKit server. (example: `wss://my-livekit-project.livekit.cloud`)
LIVEKIT_URL=
# API key and secret. If you use LiveKit Cloud this can be generated via the cloud dashboard.
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# URL pointing to the LiveKit server.
LIVEKIT_URL=wss://my-livekit-project.livekit.cloud
# OPTIONAL SETTINGS
# #################
# Recording
# S3_KEY_ID=
# S3_KEY_SECRET=
# S3_ENDPOINT=
# S3_BUCKET=
# S3_REGION=
# PUBLIC
# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters.
# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true
# NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record
# Optional, to pipe logs to datadog
# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token
# NEXT_PUBLIC_DATADOG_SITE=datadog-site
## PUBLIC
NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token

1
.gitattributes vendored
View File

@ -1 +0,0 @@
public/background-images/*.jpg filter=lfs diff=lfs merge=lfs -text

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 B

View File

@ -1,31 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 B

View File

@ -1,16 +0,0 @@
# .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 }}

View File

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

View File

@ -18,7 +18,7 @@
<br>
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.
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 screenshot](./.github/assets/livekit-meet.jpg)

View File

@ -1,89 +0,0 @@
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';
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) {
try {
// Parse query parameters
const roomName = request.nextUrl.searchParams.get('roomName');
const participantName = request.nextUrl.searchParams.get('participantName');
const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
const region = request.nextUrl.searchParams.get('region');
if (!LIVEKIT_URL) {
throw new Error('LIVEKIT_URL is not defined');
}
const livekitServerUrl = region ? getLiveKitURL(LIVEKIT_URL, region) : LIVEKIT_URL;
let randomParticipantPostfix = request.cookies.get(COOKIE_KEY)?.value;
if (livekitServerUrl === undefined) {
throw new Error('Invalid region');
}
if (typeof roomName !== 'string') {
return new NextResponse('Missing required query parameter: roomName', { status: 400 });
}
if (participantName === null) {
return new NextResponse('Missing required query parameter: participantName', { status: 400 });
}
// Generate participant token
if (!randomParticipantPostfix) {
randomParticipantPostfix = randomString(4);
}
const participantToken = await createParticipantToken(
{
identity: `${participantName}__${randomParticipantPostfix}`,
name: participantName,
metadata,
},
roomName,
);
// Return connection details
const data: ConnectionDetails = {
serverUrl: livekitServerUrl,
roomName: roomName,
participantToken: participantToken,
participantName: participantName,
};
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) {
if (error instanceof Error) {
return new NextResponse(error.message, { status: 500 });
}
}
}
function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
const at = new AccessToken(API_KEY, API_SECRET, userInfo);
at.ttl = '5m';
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
};
at.addGrant(grant);
return at.toJwt();
}
function getCookieExpirationTime(): string {
var now = new Date();
var time = now.getTime();
var expireTime = time + 60 * 120 * 1000;
now.setTime(expireTime);
return now.toUTCString();
}

View File

@ -1,70 +0,0 @@
import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
try {
const roomName = req.nextUrl.searchParams.get('roomName');
/**
* CAUTION:
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
* to start/stop recordings for that room.
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
*/
if (roomName === null) {
return new NextResponse('Missing roomName parameter', { status: 403 });
}
const {
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
LIVEKIT_URL,
S3_KEY_ID,
S3_KEY_SECRET,
S3_BUCKET,
S3_ENDPOINT,
S3_REGION,
} = process.env;
const hostURL = new URL(LIVEKIT_URL!);
hostURL.protocol = 'https:';
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const existingEgresses = await egressClient.listEgress({ roomName });
if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) {
return new NextResponse('Meeting is already being recorded', { status: 409 });
}
const fileOutput = new EncodedFileOutput({
filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`,
output: {
case: 's3',
value: new S3Upload({
endpoint: S3_ENDPOINT,
accessKey: S3_KEY_ID,
secret: S3_KEY_SECRET,
region: S3_REGION,
bucket: S3_BUCKET,
}),
},
});
await egressClient.startRoomCompositeEgress(
roomName,
{
file: fileOutput,
},
{
layout: 'speaker',
},
);
return new NextResponse(null, { status: 200 });
} catch (error) {
if (error instanceof Error) {
return new NextResponse(error.message, { status: 500 });
}
}
}

View File

@ -1,39 +0,0 @@
import { EgressClient } from 'livekit-server-sdk';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
try {
const roomName = req.nextUrl.searchParams.get('roomName');
/**
* CAUTION:
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
* to start/stop recordings for that room.
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
*/
if (roomName === null) {
return new NextResponse('Missing roomName parameter', { status: 403 });
}
const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env;
const hostURL = new URL(LIVEKIT_URL!);
hostURL.protocol = 'https:';
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const activeEgresses = (await egressClient.listEgress({ roomName })).filter(
(info) => info.status < 2,
);
if (activeEgresses.length === 0) {
return new NextResponse('No active recording found', { status: 404 });
}
await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId)));
return new NextResponse(null, { status: 200 });
} catch (error) {
if (error instanceof Error) {
return new NextResponse(error.message, { status: 500 });
}
}
}

View File

@ -1,98 +0,0 @@
'use client';
import { formatChatMessageLinks, RoomContext, VideoConference } from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
LogLevel,
Room,
RoomConnectOptions,
RoomOptions,
VideoPresets,
type VideoCodec,
} from 'livekit-client';
import { DebugMode } from '@/lib/Debug';
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 keyProvider = new ExternalE2EEKeyProvider();
const { worker, e2eePassphrase } = useSetupE2EE();
const e2eeEnabled = !!(e2eePassphrase && worker);
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false);
const roomOptions = useMemo((): RoomOptions => {
return {
publishDefaults: {
videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec: props.codec,
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
singlePeerConnection: props.singlePeerConnection,
};
}, [e2eeEnabled, props.codec, keyProvider, worker]);
const room = useMemo(() => new Room(roomOptions), [roomOptions]);
const connectOptions = useMemo((): RoomConnectOptions => {
return {
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 (
<div className="lk-room-container">
<RoomContext.Provider value={room}>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
}
/>
<DebugMode logLevel={LogLevel.debug} />
</RoomContext.Provider>
</div>
);
}

View File

@ -1,34 +0,0 @@
import { videoCodecs } from 'livekit-client';
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
import { isVideoCodec } from '@/lib/types';
export default async function CustomRoomConnection(props: {
searchParams: Promise<{
liveKitUrl?: string;
token?: string;
codec?: string;
singlePC?: string;
}>;
}) {
const { liveKitUrl, token, codec, singlePC } = await props.searchParams;
if (typeof liveKitUrl !== 'string') {
return <h2>Missing LiveKit URL</h2>;
}
if (typeof token !== 'string') {
return <h2>Missing LiveKit token</h2>;
}
if (codec !== undefined && !isVideoCodec(codec)) {
return <h2>Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].</h2>;
}
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
<VideoConferenceClientImpl
liveKitUrl={liveKitUrl}
token={token}
codec={codec}
singlePeerConnection={singlePC === 'true'}
/>
</main>
);
}

View File

@ -1,60 +0,0 @@
import '../styles/globals.css';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import type { Metadata, Viewport } from 'next';
import { Toaster } from 'react-hot-toast';
export const metadata: Metadata = {
title: {
default: 'LiveKit Meet | Conference app build with LiveKit open source',
template: '%s',
},
description:
'LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications.',
twitter: {
creator: '@livekitted',
site: '@livekitted',
card: 'summary_large_image',
},
openGraph: {
url: 'https://meet.livekit.io',
images: [
{
url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png',
width: 2000,
height: 1000,
type: 'image/png',
},
],
siteName: 'LiveKit Meet',
},
icons: {
icon: {
rel: 'icon',
url: '/favicon.ico',
},
apple: [
{
rel: 'apple-touch-icon',
url: '/images/livekit-apple-touch.png',
sizes: '180x180',
},
{ rel: 'mask-icon', url: '/images/livekit-safari-pinned-tab.svg', color: '#070707' },
],
},
};
export const viewport: Viewport = {
themeColor: '#070707',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body data-lk-theme="default">
<Toaster />
{children}
</body>
</html>
);
}

View File

@ -1,233 +0,0 @@
'use client';
import React from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import { DebugMode } from '@/lib/Debug';
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { ConnectionDetails } from '@/lib/types';
import {
formatChatMessageLinks,
LocalUserChoices,
PreJoin,
RoomContext,
VideoConference,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
VideoCodec,
VideoPresets,
Room,
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';
const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true';
export function PageClientImpl(props: {
roomName: string;
region?: string;
hq: boolean;
codec: VideoCodec;
}) {
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
const [connectionDetails, setConnectionDetails] = React.useState<ConnectionDetails | undefined>(
undefined,
);
const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => {
setPreJoinChoices(values);
const url = new URL(CONN_DETAILS_ENDPOINT, window.location.origin);
url.searchParams.append('roomName', props.roomName);
url.searchParams.append('participantName', values.username);
if (props.region) {
url.searchParams.append('region', props.region);
}
const connectionDetailsResp = await fetch(url.toString());
const connectionDetailsData = await connectionDetailsResp.json();
setConnectionDetails(connectionDetailsData);
}, []);
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
{connectionDetails === undefined || preJoinChoices === undefined ? (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoin
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
onError={handlePreJoinError}
/>
</div>
) : (
<VideoConferenceComponent
connectionDetails={connectionDetails}
userChoices={preJoinChoices}
options={{ codec: props.codec, hq: props.hq }}
/>
)}
</main>
);
}
function VideoConferenceComponent(props: {
userChoices: LocalUserChoices;
connectionDetails: ConnectionDetails;
options: {
hq: boolean;
codec: VideoCodec;
};
}) {
const keyProvider = new ExternalE2EEKeyProvider();
const { worker, e2eePassphrase } = useSetupE2EE();
const e2eeEnabled = !!(e2eePassphrase && worker);
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
const roomOptions = React.useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
const videoCaptureDefaults: VideoCaptureOptions = {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
};
const publishDefaults: TrackPublishDefaults = {
dtx: false,
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
};
return {
videoCaptureDefaults: videoCaptureDefaults,
publishDefaults: publishDefaults,
audioCaptureDefaults: {
deviceId: props.userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: true,
dynacast: true,
e2ee: keyProvider && worker && e2eeEnabled ? { keyProvider, worker } : undefined,
singlePeerConnection: true,
};
}, [props.userChoices, props.options.hq, props.options.codec]);
const room = React.useMemo(() => new Room(roomOptions), []);
React.useEffect(() => {
if (e2eeEnabled) {
keyProvider
.setKey(decodePassphrase(e2eePassphrase))
.then(() => {
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
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 => {
return {
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 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 (
<div className="lk-room-container">
<RoomContext.Provider value={room}>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
<DebugMode />
<RecordingIndicator />
</RoomContext.Provider>
</div>
);
}

View File

@ -1,33 +0,0 @@
import * as React from 'react';
import { PageClientImpl } from './PageClientImpl';
import { isVideoCodec } from '@/lib/types';
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ roomName: string }>;
searchParams: Promise<{
// FIXME: We should not allow values for regions if in playground mode.
region?: string;
hq?: string;
codec?: string;
}>;
}) {
const _params = await params;
const _searchParams = await searchParams;
const codec =
typeof _searchParams.codec === 'string' && isVideoCodec(_searchParams.codec)
? _searchParams.codec
: 'vp9';
const hq = _searchParams.hq === 'true' ? true : false;
return (
<PageClientImpl
roomName={_params.roomName}
region={_searchParams.region}
hq={hq}
codec={codec}
/>
);
}

View File

@ -1,175 +0,0 @@
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>
);
}

View File

@ -1,10 +1,7 @@
import * as React from 'react';
import { useRoomContext } from '@livekit/components-react';
import { setLogLevel, LogLevel, RemoteTrackPublication, setLogExtension } from 'livekit-client';
// @ts-ignore
import { setLogLevel, LogLevel, RemoteTrackPublication } from 'livekit-client';
import { tinykeys } from 'tinykeys';
import { datadogLogs } from '@datadog/browser-logs';
import styles from '../styles/Debug.module.css';
export const useDebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
@ -12,36 +9,6 @@ export const useDebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
React.useEffect(() => {
setLogLevel(logLevel ?? 'debug');
if (process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN && process.env.NEXT_PUBLIC_DATADOG_SITE) {
console.log('setting up datadog logs');
datadogLogs.init({
clientToken: process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN,
site: process.env.NEXT_PUBLIC_DATADOG_SITE,
forwardErrorsToLogs: true,
sessionSampleRate: 100,
});
setLogExtension((level, msg, context) => {
switch (level) {
case LogLevel.debug:
datadogLogs.logger.debug(msg, context);
break;
case LogLevel.info:
datadogLogs.logger.info(msg, context);
break;
case LogLevel.warn:
datadogLogs.logger.warn(msg, context);
break;
case LogLevel.error:
datadogLogs.logger.error(msg, context);
break;
default:
break;
}
});
}
// @ts-expect-error
window.__lk_room = room;
@ -56,11 +23,6 @@ export const DebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
const room = useRoomContext();
const [isOpen, setIsOpen] = React.useState(false);
const [, setRender] = React.useState({});
const [roomSid, setRoomSid] = React.useState('');
React.useEffect(() => {
room.getSid().then(setRoomSid);
}, [room]);
useDebugMode({ logLevel });
@ -116,7 +78,7 @@ export const DebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
<div className={styles.overlay}>
<section id="room-info">
<h3>
Room Info {room.name}: {roomSid}
Room Info {room.name}: {room.sid}
</h3>
</section>
<details open>
@ -128,7 +90,7 @@ export const DebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
<b>Published tracks</b>
</summary>
<div>
{Array.from(lp.trackPublications.values()).map((t) => (
{Array.from(lp.tracks.values()).map((t) => (
<>
<div>
<i>
@ -189,7 +151,7 @@ export const DebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
<summary>
<b>Remote Participants</b>
</summary>
{Array.from(room.remoteParticipants.values()).map((p) => (
{Array.from(room.participants.values()).map((p) => (
<details key={p.sid} className={styles.detailsSection}>
<summary>
<b>
@ -198,7 +160,7 @@ export const DebugMode = ({ logLevel }: { logLevel?: LogLevel }) => {
</b>
</summary>
<div>
{Array.from(p.trackPublications.values()).map((t) => (
{Array.from(p.tracks.values()).map((t) => (
<>
<div>
<i>

View File

@ -1,31 +0,0 @@
'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;
}

View File

@ -1,55 +0,0 @@
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>
);
}

View File

@ -1,40 +0,0 @@
import { useIsRecording } from '@livekit/components-react';
import * as React from 'react';
import toast from 'react-hot-toast';
export function RecordingIndicator() {
const isRecording = useIsRecording();
const [wasRecording, setWasRecording] = React.useState(false);
React.useEffect(() => {
if (isRecording !== wasRecording) {
setWasRecording(isRecording);
if (isRecording) {
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]);
return (
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
boxShadow: isRecording ? 'var(--lk-danger3) 0px 0px 0px 3px inset' : 'none',
pointerEvents: 'none',
}}
></div>
);
}

View File

@ -1,154 +0,0 @@
'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';
/**
* @alpha
*/
export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement> {}
/**
* @alpha
*/
export function SettingsMenu(props: SettingsMenuProps) {
const layoutContext = useMaybeLayoutContext();
const room = useRoomContext();
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
const settings = React.useMemo(() => {
return {
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
};
}, []);
const tabs = React.useMemo(
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
[settings],
);
const [activeTab, setActiveTab] = React.useState(tabs[0]);
const isRecording = useIsRecording();
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
const [processingRecRequest, setProcessingRecRequest] = React.useState(false);
React.useEffect(() => {
if (initialRecStatus !== isRecording) {
setProcessingRecRequest(false);
}
}, [isRecording, initialRecStatus]);
const toggleRoomRecording = async () => {
if (!recordingEndpoint) {
throw TypeError('No recording endpoint specified');
}
if (room.isE2EEEnabled) {
throw Error('Recording of encrypted meetings is currently not supported');
}
setProcessingRecRequest(true);
setInitialRecStatus(isRecording);
let response: Response;
if (isRecording) {
response = await fetch(recordingEndpoint + `/stop?roomName=${room.name}`);
} else {
response = await fetch(recordingEndpoint + `/start?roomName=${room.name}`);
}
if (response.ok) {
} else {
console.error(
'Error handling recording request, check server logs:',
response.status,
response.statusText,
);
setProcessingRecRequest(false);
}
};
return (
<div className="settings-menu" style={{ width: '100%', position: 'relative' }} {...props}>
<div className={styles.tabs}>
{tabs.map(
(tab) =>
settings[tab] && (
<button
className={`${styles.tab} lk-button`}
key={tab}
onClick={() => setActiveTab(tab)}
aria-pressed={tab === activeTab}
>
{
// @ts-ignore
settings[tab].label
}
</button>
),
)}
</div>
<div className="tab-content">
{activeTab === 'media' && (
<>
{settings.media && settings.media.camera && (
<>
<h3>Camera</h3>
<section>
<CameraSettings />
</section>
</>
)}
{settings.media && settings.media.microphone && (
<>
<h3>Microphone</h3>
<section>
<MicrophoneSettings />
</section>
</>
)}
{settings.media && settings.media.speaker && (
<>
<h3>Speaker & Headphones</h3>
<section className="lk-button-group">
<span className="lk-button">Audio Output</span>
<div className="lk-button-group-menu">
<MediaDeviceMenu kind="audiooutput"></MediaDeviceMenu>
</div>
</section>
</>
)}
</>
)}
{activeTab === 'recording' && (
<>
<h3>Record Meeting</h3>
<section>
<p>
{isRecording
? 'Meeting is currently being recorded'
: 'No active recordings for this meeting'}
</p>
<button disabled={processingRecRequest} onClick={() => toggleRoomRecording()}>
{isRecording ? 'Stop' : 'Start'} Recording
</button>
</section>
</>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<button
className={`lk-button`}
onClick={() => layoutContext?.widget.dispatch?.({ msg: 'toggle_settings' })}
>
Close
</button>
</div>
</div>
);
}

View File

@ -1,3 +1,25 @@
import { useEffect, useState } from 'react';
export function useServerUrl(region?: string) {
const [serverUrl, setServerUrl] = useState<string | undefined>();
useEffect(() => {
let endpoint = `/api/url`;
if (region) {
endpoint += `?region=${region}`;
}
fetch(endpoint).then(async (res) => {
if (res.ok) {
const body = await res.json();
console.log(body);
setServerUrl(body.url);
} else {
throw Error('Error fetching server url, check server logs');
}
});
});
return serverUrl;
}
export function encodePassphrase(passphrase: string) {
return encodeURIComponent(passphrase);
}
@ -19,11 +41,3 @@ export function randomString(length: number): string {
}
return result;
}
export function isLowPowerDevice() {
return navigator.hardwareConcurrency < 6;
}
export function isMeetStaging() {
return new URL(location.origin).host === 'meet.staging.livekit.io';
}

View File

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

View File

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

27
lib/server-utils.ts Normal file
View File

@ -0,0 +1,27 @@
import { RoomServiceClient } from 'livekit-server-sdk';
export function getRoomClient(): RoomServiceClient {
checkKeys();
return new RoomServiceClient(getLiveKitURL());
}
export function getLiveKitURL(region?: string | string[]): string {
let targetKey = 'LIVEKIT_URL';
if (region && !Array.isArray(region)) {
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
}
const url = process.env[targetKey];
if (!url) {
throw new Error(`${targetKey} is not defined`);
}
return url;
}
function checkKeys() {
if (typeof process.env.LIVEKIT_API_KEY === 'undefined') {
throw new Error('LIVEKIT_API_KEY is not defined');
}
if (typeof process.env.LIVEKIT_API_SECRET === 'undefined') {
throw new Error('LIVEKIT_API_SECRET is not defined');
}
}

View File

@ -1,5 +1,4 @@
import { LocalAudioTrack, LocalVideoTrack, videoCodecs } from 'livekit-client';
import { VideoCodec } from 'livekit-client';
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
export interface SessionProps {
roomName: string;
@ -15,14 +14,3 @@ export interface TokenResult {
identity: string;
accessToken: string;
}
export function isVideoCodec(codec: string): codec is VideoCodec {
return videoCodecs.includes(codec as VideoCodec);
}
export type ConnectionDetails = {
serverUrl: string;
roomName: string;
participantName: string;
participantToken: string;
};

View File

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

View File

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

2
next-env.d.ts vendored
View File

@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,36 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
swcMinify: false,
productionBrowserSourceMaps: true,
images: {
formats: ['image/webp'],
},
webpack: (config, { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }) => {
// Important: return the modified config
config.module.rules.push({
test: /\.mjs$/,
enforce: 'pre',
use: ['source-map-loader'],
});
return config;
},
headers: async () => {
return [
config.module.rules = [
...config.module.rules,
{
source: '/(.*)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'credentialless',
},
],
test: /\.mjs$/,
enforce: 'pre',
use: ['source-map-loader'],
},
];
return config;
},
};

View File

@ -6,39 +6,29 @@
"dev": "next dev",
"build": "next build",
"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}\""
"lint": "next lint"
},
"dependencies": {
"@datadog/browser-logs": "^5.23.3",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/krisp-noise-filter": "0.4.1",
"@livekit/track-processors": "^0.7.0",
"livekit-client": "2.17.2",
"livekit-server-sdk": "2.15.0",
"next": "15.2.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"tinykeys": "^3.0.0"
"@livekit/components-react": "1.5.3",
"@livekit/components-styles": "1.0.9",
"livekit-client": "1.15.12",
"livekit-server-sdk": "2.0.0",
"next": "14.1.0",
"next-seo": "^6.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"tinykeys": "^2.1.0"
},
"devDependencies": {
"@types/node": "24.10.13",
"@types/react": "18.3.27",
"@types/react-dom": "18.3.7",
"eslint": "9.39.1",
"eslint-config-next": "15.5.6",
"prettier": "3.7.3",
"@types/node": "20.11.14",
"@types/react": "18.2.49",
"@types/react-dom": "18.2.18",
"eslint": "8.56.0",
"eslint-config-next": "14.1.0",
"source-map-loader": "^5.0.0",
"typescript": "5.9.3",
"vitest": "^3.2.4"
"typescript": "5.3.3"
},
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@10.18.2"
}
}

60
pages/_app.tsx Normal file
View File

@ -0,0 +1,60 @@
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import { DefaultSeo } from 'next-seo';
function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<DefaultSeo
title="LiveKit Meet | Conference app build with LiveKit Open Source"
titleTemplate="%s"
defaultTitle="LiveKit Meet | Conference app build with LiveKit open source"
description="LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications."
twitter={{
handle: '@livekitted',
site: '@livekitted',
cardType: 'summary_large_image',
}}
openGraph={{
url: 'https://meet.livekit.io',
images: [
{
url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png',
width: 2000,
height: 1000,
type: 'image/png',
},
],
site_name: 'LiveKit Meet',
}}
additionalMetaTags={[
{
property: 'theme-color',
content: '#070707',
},
]}
additionalLinkTags={[
{
rel: 'icon',
href: '/favicon.ico',
},
{
rel: 'apple-touch-icon',
href: '/images/livekit-apple-touch.png',
sizes: '180x180',
},
{
rel: 'mask-icon',
href: '/images/livekit-safari-pinned-tab.svg',
color: '#070707',
},
]}
/>
<Component {...pageProps} />
</>
);
}
export default MyApp;

67
pages/api/token.ts Normal file
View File

@ -0,0 +1,67 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { AccessToken } from 'livekit-server-sdk';
import type { AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
import { TokenResult } from '../../lib/types';
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
const createToken = (userInfo: AccessTokenOptions, grant: VideoGrant) => {
const at = new AccessToken(apiKey, apiSecret, userInfo);
at.ttl = '5m';
at.addGrant(grant);
return at.toJwt();
};
const roomPattern = /\w{4}\-\w{4}/;
export default async function handleToken(req: NextApiRequest, res: NextApiResponse) {
try {
const { roomName, identity, name, metadata } = req.query;
if (typeof identity !== 'string' || typeof roomName !== 'string') {
res.status(403).end();
return;
}
if (Array.isArray(name)) {
throw Error('provide max one name');
}
if (Array.isArray(metadata)) {
throw Error('provide max one metadata string');
}
// enforce room name to be xxxx-xxxx
// this is simple & naive way to prevent user from guessing room names
// please use your own authentication mechanisms in your own app
if (!roomName.match(roomPattern)) {
res.status(400).end();
return;
}
// if (!userSession.isAuthenticated) {
// res.status(403).end();
// return;
// }
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
};
const token = await createToken({ identity, name, metadata }, grant);
const result: TokenResult = {
identity,
accessToken: token,
};
res.status(200).json(result);
} catch (e) {
res.statusMessage = (e as Error).message;
res.status(500).end();
}
}

17
pages/api/url.ts Normal file
View File

@ -0,0 +1,17 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getLiveKitURL } from '../../lib/server-utils';
export default async function handleServerUrl(req: NextApiRequest, res: NextApiResponse) {
try {
const { region } = req.query;
if (Array.isArray(region)) {
throw Error('provide max one region string');
}
const url = getLiveKitURL(region);
res.status(200).json({ url });
} catch (e) {
res.statusMessage = (e as Error).message;
res.status(500).end();
}
}

82
pages/custom/index.tsx Normal file
View File

@ -0,0 +1,82 @@
import { formatChatMessageLinks, LiveKitRoom, VideoConference } from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
LogLevel,
Room,
RoomConnectOptions,
RoomOptions,
VideoCodec,
VideoPresets,
} from 'livekit-client';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { decodePassphrase } from '../../lib/client-utils';
import { DebugMode } from '../../lib/Debug';
export default function CustomRoomConnection() {
const router = useRouter();
const { liveKitUrl, token, codec } = router.query;
const e2eePassphrase =
typeof window !== 'undefined' && decodePassphrase(window.location.hash.substring(1));
const worker =
typeof window !== 'undefined' &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const keyProvider = new ExternalE2EEKeyProvider();
const e2eeEnabled = !!(e2eePassphrase && worker);
const roomOptions = useMemo((): RoomOptions => {
return {
publishDefaults: {
videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec: codec as VideoCodec | undefined,
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
};
}, []);
const room = useMemo(() => new Room(roomOptions), []);
if (e2eeEnabled) {
keyProvider.setKey(e2eePassphrase);
room.setE2EEEnabled(true);
}
const connectOptions = useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
};
}, []);
if (typeof liveKitUrl !== 'string') {
return <h2>Missing LiveKit URL</h2>;
}
if (typeof token !== 'string') {
return <h2>Missing LiveKit token</h2>;
}
return (
<main data-lk-theme="default">
{liveKitUrl && (
<LiveKitRoom
room={room}
token={token}
connectOptions={connectOptions}
serverUrl={liveKitUrl}
audio={true}
video={true}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
<DebugMode logLevel={LogLevel.debug} />
</LiveKitRoom>
)}
</main>
);
}

View File

@ -1,18 +1,19 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense, useState } from 'react';
import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils';
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useRouter } from 'next/router';
import React, { ReactElement, useState } from 'react';
import { encodePassphrase, generateRoomId, randomString } from '../lib/client-utils';
import styles from '../styles/Home.module.css';
function Tabs(props: React.PropsWithChildren<{}>) {
const searchParams = useSearchParams();
const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
interface TabsProps {
children: ReactElement[];
selectedIndex?: number;
onTabSelected?: (index: number) => void;
}
const router = useRouter();
function onTabSelected(index: number) {
const tab = index === 1 ? 'custom' : 'demo';
router.push(`/?tab=${tab}`);
function Tabs(props: TabsProps) {
const activeIndex = props.selectedIndex ?? 0;
if (!props.children) {
return <></>;
}
let tabs = React.Children.map(props.children, (child, index) => {
@ -20,28 +21,23 @@ function Tabs(props: React.PropsWithChildren<{}>) {
<button
className="lk-button"
onClick={() => {
if (onTabSelected) {
onTabSelected(index);
}
if (props.onTabSelected) props.onTabSelected(index);
}}
aria-pressed={tabIndex === index}
aria-pressed={activeIndex === index}
>
{/* @ts-ignore */}
{child?.props.label}
</button>
);
});
return (
<div className={styles.tabContainer}>
<div className={styles.tabSelect}>{tabs}</div>
{/* @ts-ignore */}
{props.children[tabIndex]}
{props.children[activeIndex]}
</div>
);
}
function DemoMeetingTab(props: { label: string }) {
function DemoMeetingTab({ label }: { label: string }) {
const router = useRouter();
const [e2ee, setE2ee] = useState(false);
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
@ -84,7 +80,7 @@ function DemoMeetingTab(props: { label: string }) {
);
}
function CustomConnectionTab(props: { label: string }) {
function CustomConnectionTab({ label }: { label: string }) {
const router = useRouter();
const [e2ee, setE2ee] = useState(false);
@ -160,7 +156,21 @@ function CustomConnectionTab(props: { label: string }) {
);
}
export default function Page() {
export const getServerSideProps: GetServerSideProps<{ tabIndex: number }> = async ({
query,
res,
}) => {
res.setHeader('Cache-Control', 'public, max-age=7200');
const tabIndex = query.tab === 'custom' ? 1 : 0;
return { props: { tabIndex } };
};
const Home = ({ tabIndex }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const router = useRouter();
function onTabSelected(index: number) {
const tab = index === 1 ? 'custom' : 'demo';
router.push({ query: { tab } });
}
return (
<>
<main className={styles.main} data-lk-theme="default">
@ -178,12 +188,10 @@ export default function Page() {
and Next.js.
</h2>
</div>
<Suspense fallback="Loading">
<Tabs>
<DemoMeetingTab label="Demo" />
<CustomConnectionTab label="Custom" />
</Tabs>
</Suspense>
<Tabs selectedIndex={tabIndex} onTabSelected={onTabSelected}>
<DemoMeetingTab label="Demo" />
<CustomConnectionTab label="Custom" />
</Tabs>
</main>
<footer data-lk-theme="default">
Hosted on{' '}
@ -198,4 +206,6 @@ export default function Page() {
</footer>
</>
);
}
};
export default Home;

186
pages/rooms/[name].tsx Normal file
View File

@ -0,0 +1,186 @@
'use client';
import {
LiveKitRoom,
VideoConference,
formatChatMessageLinks,
useToken,
LocalUserChoices,
} from '@livekit/components-react';
import {
DeviceUnsupportedError,
ExternalE2EEKeyProvider,
LogLevel,
Room,
RoomConnectOptions,
RoomOptions,
VideoCodec,
VideoPresets,
} from 'livekit-client';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import { useRouter } from 'next/router';
import * as React from 'react';
import { DebugMode } from '../../lib/Debug';
import { decodePassphrase, useServerUrl } from '../../lib/client-utils';
const PreJoinNoSSR = dynamic(
async () => {
return (await import('@livekit/components-react')).PreJoin;
},
{ ssr: false },
);
const Home: NextPage = () => {
const router = useRouter();
const { name: roomName } = router.query;
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
undefined,
);
function handlePreJoinSubmit(values: LocalUserChoices) {
setPreJoinChoices(values);
}
return (
<>
<Head>
<title>LiveKit Meet</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main data-lk-theme="default">
{roomName && !Array.isArray(roomName) && preJoinChoices ? (
<ActiveRoom
roomName={roomName}
userChoices={preJoinChoices}
onLeave={() => {
router.push('/');
}}
></ActiveRoom>
) : (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoinNoSSR
onError={(err) => console.log('error while setting up prejoin', err)}
defaults={{
username: '',
videoEnabled: true,
audioEnabled: true,
}}
onSubmit={handlePreJoinSubmit}
></PreJoinNoSSR>
</div>
)}
</main>
</>
);
};
export default Home;
type ActiveRoomProps = {
userChoices: LocalUserChoices;
roomName: string;
region?: string;
onLeave?: () => void;
};
const ActiveRoom = ({ roomName, userChoices, onLeave }: ActiveRoomProps) => {
const tokenOptions = React.useMemo(() => {
return {
userInfo: {
identity: userChoices.username,
name: userChoices.username,
},
};
}, [userChoices.username]);
const token = useToken(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT, roomName, tokenOptions);
const router = useRouter();
const { region, hq, codec } = router.query;
const e2eePassphrase =
typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1));
const liveKitUrl = useServerUrl(region as string | undefined);
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 roomOptions = React.useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = (
Array.isArray(codec) ? codec[0] : codec ?? 'vp9'
) as VideoCodec;
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
return {
videoCaptureDefaults: {
deviceId: userChoices.videoDeviceId ?? undefined,
resolution: hq === 'true' ? VideoPresets.h2160 : VideoPresets.h720,
},
publishDefaults: {
dtx: false,
videoSimulcastLayers:
hq === 'true'
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
},
audioCaptureDefaults: {
deviceId: userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
};
}, [userChoices, hq, codec]);
const room = React.useMemo(() => new Room(roomOptions), []);
if (e2eeEnabled) {
keyProvider.setKey(decodePassphrase(e2eePassphrase));
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
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);
}
});
}
const connectOptions = React.useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
};
}, []);
return (
<>
{liveKitUrl && (
<LiveKitRoom
room={room}
token={token}
serverUrl={liveKitUrl}
connectOptions={connectOptions}
video={userChoices.videoEnabled}
audio={userChoices.audioEnabled}
onDisconnected={onLeave}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
<DebugMode logLevel={LogLevel.debug} />
</LiveKitRoom>
)}
</>
);
};

6158
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a3c9eb8da1ef3ddf2439428b49c11abd9a765e056600bd4f1d89a5dfc82778a
size 52339

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bc017736e04acb0188f69cec0aafb88bd6891bfc4a6ff1530665e8dc210dbdf
size 1273171

View File

@ -1,17 +1,17 @@
{
"extends": ["config:base"],
"packageRules": [
{
"schedule": "before 6am on the first day of the month",
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "devDependencies (non-major)"
},
{
"matchSourceUrlPrefixes": ["https://github.com/livekit/"],
"rangeStrategy": "replace",
"groupName": "LiveKit dependencies (non-major)",
"automerge": true
},
{
"schedule": "before 6am on the first day of the month",
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "devDependencies (non-major)"
}
]
}

View File

@ -1,12 +1,11 @@
.main {
position: relative;
display: grid;
gap: 1rem;
justify-content: center;
place-content: center;
justify-items: center;
padding-bottom: 100px;
overflow: auto;
flex-grow: 1;
}
.tabContainer {

View File

@ -1,17 +0,0 @@
.tabs {
position: relative;
display: flex;
align-content: space-between;
}
.tabs > .tab {
padding: 0.5rem;
border-radius: 0;
padding-bottom: 0.5rem;
border-bottom: 3px solid;
border-color: var(--bg5);
}
.tabs > .tab[aria-pressed='true'] {
border-color: var(--lk-accent-bg);
}

View File

@ -10,16 +10,17 @@ html {
html,
body {
overflow: hidden;
}
html,
body,
#__next,
main {
width: 100%;
height: 100%;
margin: 0px;
}
body {
display: flex;
flex-direction: column;
}
.header {
max-width: 500px;
padding-inline: 2rem;
@ -42,6 +43,8 @@ body {
}
footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 1.5rem 2rem;
text-align: center;

View File

@ -1,29 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "ES2020"],
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ES2020",
"moduleResolution": "Bundler",
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"sourceMap": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
"sourceMap": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}