Compare commits

..

1 Commits

Author SHA1 Message Date
GitButler
e82043cbd1 GitButler Integration Commit
This is an integration commit for the virtual branches that GitButler is tracking.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

Here are the branches that are currently applied:
For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/virtual-branches/integration-branch
2024-04-29 19:31:28 +02:00
45 changed files with 2013 additions and 4077 deletions

View File

@ -1,30 +1,17 @@
# 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
## PUBLIC
NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token
# 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

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,7 +1,6 @@
import * as React from 'react';
import { useRoomContext } from '@livekit/components-react';
import { setLogLevel, LogLevel, RemoteTrackPublication, setLogExtension } from 'livekit-client';
// @ts-ignore
import { tinykeys } from 'tinykeys';
import { datadogLogs } from '@datadog/browser-logs';

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,16 +1,14 @@
'use client';
import * as React from 'react';
import { Track } from 'livekit-client';
import { LocalAudioTrack, Track } from 'livekit-client';
import {
useMaybeLayoutContext,
useLocalParticipant,
MediaDeviceMenu,
TrackToggle,
useRoomContext,
useIsRecording,
} from '@livekit/components-react';
import styles from '../styles/SettingsMenu.module.css';
import { CameraSettings } from './CameraSettings';
import { MicrophoneSettings } from './MicrophoneSettings';
/**
* @alpha
*/
@ -21,60 +19,49 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
*/
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,
media: { camera: true, microphone: true, label: 'Media Devices', speaker: false },
effects: { label: 'Effects' },
};
}, []);
const tabs = React.useMemo(
() => Object.keys(settings).filter((t) => t !== undefined) as Array<keyof typeof settings>,
() => Object.keys(settings) as Array<keyof typeof settings>,
[settings],
);
const [activeTab, setActiveTab] = React.useState(tabs[0]);
const { microphoneTrack } = useLocalParticipant();
const isRecording = useIsRecording();
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
const [processingRecRequest, setProcessingRecRequest] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(tabs[0]);
const [isNoiseFilterEnabled, setIsNoiseFilterEnabled] = React.useState(true);
React.useEffect(() => {
if (initialRecStatus !== isRecording) {
setProcessingRecRequest(false);
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));
}
}
}, [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);
}
};
}, [isNoiseFilterEnabled, microphoneTrack]);
return (
<div className="settings-menu" style={{ width: '100%', position: 'relative' }} {...props}>
<div className="settings-menu" style={{ width: '100%' }} {...props}>
<div className={styles.tabs}>
{tabs.map(
(tab) =>
@ -99,56 +86,56 @@ export function SettingsMenu(props: SettingsMenuProps) {
{settings.media && settings.media.camera && (
<>
<h3>Camera</h3>
<section>
<CameraSettings />
<section className="lk-button-group">
<TrackToggle source={Track.Source.Camera}>Camera</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu kind="videoinput" />
</div>
</section>
</>
)}
{settings.media && settings.media.microphone && (
<>
<h3>Microphone</h3>
<section>
<MicrophoneSettings />
<section className="lk-button-group">
<TrackToggle source={Track.Source.Microphone}>Microphone</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu kind="audioinput" />
</div>
</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>
<MediaDeviceMenu kind="audiooutput"></MediaDeviceMenu>
</section>
</>
)}
</>
)}
{activeTab === 'recording' && (
{activeTab === 'effects' && (
<>
<h3>Record Meeting</h3>
<h3>Audio</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>
<label htmlFor="noise-filter"> Enhanced Noise Cancellation</label>
<input
type="checkbox"
id="noise-filter"
onChange={(ev) => setIsNoiseFilterEnabled(ev.target.checked)}
checked={isNoiseFilterEnabled}
></input>
</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>
<button
className={`lk-button ${styles.settingsCloseButton}`}
onClick={() => layoutContext?.widget.dispatch?.({ msg: 'toggle_settings' })}
>
Close
</button>
</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,10 +1,8 @@
/** @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({
@ -12,26 +10,8 @@ const nextConfig = {
enforce: 'pre',
use: ['source-map-loader'],
});
return config;
},
headers: async () => {
return [
{
source: '/(.*)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'credentialless',
},
],
},
];
},
};
module.exports = nextConfig;

View File

@ -6,39 +6,34 @@
"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"
"@datadog/browser-logs": "^5.10.0",
"@livekit/components-react": "2.2.0",
"@livekit/components-styles": "1.0.11",
"@livekit/krisp-noise-filter": "^0.2.0",
"livekit-client": "2.1.1",
"livekit-server-sdk": "2.2.0",
"next": "14.2.2",
"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.12.3",
"@types/react": "18.2.74",
"@types/react-dom": "18.2.23",
"eslint": "8.57.0",
"eslint-config-next": "14.1.4",
"source-map-loader": "^5.0.0",
"typescript": "5.9.3",
"vitest": "^3.2.4"
"typescript": "5.4.3"
},
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@10.18.2"
"pnpm": {
"overrides": {}
}
}

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

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

@ -0,0 +1,83 @@
'use client';
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;

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

@ -0,0 +1,196 @@
'use client';
import {
LiveKitRoom,
VideoConference,
formatChatMessageLinks,
useToken,
LocalUserChoices,
PreJoin,
} from '@livekit/components-react';
import {
DeviceUnsupportedError,
ExternalE2EEKeyProvider,
Room,
RoomConnectOptions,
RoomOptions,
VideoCodec,
VideoPresets,
setLogLevel,
} from 'livekit-client';
import type { NextPage } from 'next';
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';
import { SettingsMenu } from '../../lib/SettingsMenu';
const Home: NextPage = () => {
const router = useRouter();
const { name: roomName } = router.query;
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
const handlePreJoinSubmit = React.useCallback((values: LocalUserChoices) => {
setPreJoinChoices(values);
}, []);
const onPreJoinError = React.useCallback((e: any) => {
console.error(e);
}, []);
const onLeave = React.useCallback(() => router.push('/'), []);
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={onLeave}
></ActiveRoom>
) : (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoin
onError={onPreJoinError}
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
></PreJoin>
</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,
};
// @ts-ignore
setLogLevel('debug', 'lk-e2ee');
}, [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}
SettingsComponent={
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
}
/>
<DebugMode />
</LiveKitRoom>
)}
</>
);
};

4078
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,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,3 +1,9 @@
.settingsCloseButton {
position: absolute;
right: var(--lk-grid-gap);
bottom: var(--lk-grid-gap);
}
.tabs {
position: relative;
display: flex;

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"]
}