replace hosted livekit meet with local meet link

pull/314/head
Ben Xu 4 weeks ago
parent 6084e2591a
commit bd6f530be7

@ -0,0 +1,30 @@
# 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=http://localhost:10101
# 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

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@ -0,0 +1,31 @@
<svg width="270" height="151" viewBox="0 0 270 151" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="270" height="151" fill="#070707"/>
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" fill="#131313"/>
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" stroke="#1F1F1F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M101.167 71.4998C101.167 69.6589 102.66 68.1665 104.501 68.1665C106.342 68.1665 107.834 69.6589 107.834 71.4998C107.834 73.3408 106.342 74.8332 104.501 74.8332C102.66 74.8332 101.167 73.3408 101.167 71.4998Z" fill="#666666"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.834 82.1665C97.834 78.4846 100.819 75.4998 104.501 75.4998C108.183 75.4998 111.167 78.4846 111.167 82.1665V82.8332H97.834V82.1665Z" fill="#666666"/>
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" fill="#131313"/>
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
<g clip-path="url(#clip0_834_19648)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 23.8333C232.167 21.9924 233.66 20.5 235.501 20.5C237.342 20.5 238.834 21.9924 238.834 23.8333C238.834 25.6743 237.342 27.1667 235.501 27.1667C233.66 27.1667 232.167 25.6743 232.167 23.8333Z" fill="#666666"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 34.5C228.834 30.8181 231.819 27.8333 235.501 27.8333C239.183 27.8333 242.167 30.8181 242.167 34.5V35.1667H228.834V34.5Z" fill="#666666"/>
</g>
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" fill="#131313"/>
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" stroke="#CCCCCC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 71.4998C232.167 69.6589 233.66 68.1665 235.501 68.1665C237.342 68.1665 238.834 69.6589 238.834 71.4998C238.834 73.3408 237.342 74.8332 235.501 74.8332C233.66 74.8332 232.167 73.3408 232.167 71.4998Z" fill="#CCCCCC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 82.1665C228.834 78.4846 231.819 75.4998 235.501 75.4998C239.183 75.4998 242.167 78.4846 242.167 82.1665V82.8332H228.834V82.1665Z" fill="#CCCCCC"/>
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" fill="#131313"/>
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
<g clip-path="url(#clip1_834_19648)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 119.167C232.167 117.326 233.66 115.833 235.501 115.833C237.342 115.833 238.834 117.326 238.834 119.167C238.834 121.008 237.342 122.5 235.501 122.5C233.66 122.5 232.167 121.008 232.167 119.167Z" fill="#666666"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 129.833C228.834 126.152 231.819 123.167 235.501 123.167C239.183 123.167 242.167 126.152 242.167 129.833V130.5H228.834V129.833Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_834_19648">
<rect width="16" height="16" fill="white" transform="translate(227.5 19.8335)"/>
</clipPath>
<clipPath id="clip1_834_19648">
<rect width="16" height="16" fill="white" transform="translate(227.5 115.167)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

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

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo

@ -0,0 +1,3 @@
.github/
.next/
node_modules/

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"printWidth": 100
}

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -0,0 +1,16 @@
# LiveKit Meet Client
This is a clone of the LiveKit Meet open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://livekit.io/cloud), and Next.js. Used as a simple web interface to the 01 with screenshare and camera functionality. Can be run using a fully local model, STT, and TTS.
## Usage
Run the following command in the software directory to open a Meet instance.
```
poetry run 01 --server livekit --meet
```
## Setup
Ensure that you're in the meet directory. Then run `pnpm install` to install all dependencies. You're now all set to get up and running!

@ -0,0 +1,81 @@
import { randomString } from '@/lib/client-utils';
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;
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');
const livekitServerUrl = region ? getLiveKitURL(region) : LIVEKIT_URL;
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
const participantToken = await createParticipantToken(
{
identity: `${participantName}__${randomString(4)}`,
name: participantName,
metadata,
},
roomName,
);
// Return connection details
const data: ConnectionDetails = {
serverUrl: livekitServerUrl,
roomName: roomName,
participantToken: participantToken,
participantName: participantName,
};
return NextResponse.json(data);
} 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();
}
/**
* Get the LiveKit server URL for the given region.
*/
function getLiveKitURL(region: string | null): string {
let targetKey = 'LIVEKIT_URL';
if (region) {
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
}
const url = process.env[targetKey];
if (!url) {
throw new Error(`${targetKey} is not defined`);
}
return url;
}

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

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

@ -0,0 +1,210 @@
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-react';
import { isTrackReference } from '@livekit/components-react';
import { log } from './logger';
import { isWeb } from './detectMobileBrowser';
import { isEqualTrackRef } from './track-reference';
import { RoomEvent, Track } from 'livekit-client';
import * as React from 'react';
import type { MessageFormatter } from '@livekit/components-react';
import {
CarouselLayout,
ConnectionStateToast,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
} from '@livekit/components-react';
import { useCreateLayoutContext } from '@livekit/components-react';
import { usePinnedTracks, useTracks } from '@livekit/components-react';
import { ControlBar } from '@livekit/components-react';
import { useWarnAboutMissingStyles } from './useWarnAboutMissingStyles';
import { useLocalParticipant } from '@livekit/components-react';
/**
* @public
*/
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter;
chatMessageEncoder?: MessageEncoder;
chatMessageDecoder?: MessageDecoder;
/** @alpha */
SettingsComponent?: React.ComponentType;
}
/**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
* of participants, basic non-persistent chat, screen sharing, and more.
*
* @remarks
* The component is implemented with other LiveKit components like `FocusContextProvider`,
* `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
* You can use these components as a starting point for your own custom video conferencing application.
*
* @example
* ```tsx
* <LiveKitRoom>
* <VideoConference />
* <LiveKitRoom>
* ```
* @public
*/
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
SettingsComponent,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
});
const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null);
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
);
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state);
setWidgetState(state);
};
const layoutContext = useCreateLayoutContext();
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare);
const focusTrack = usePinnedTracks(layoutContext)?.[0];
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
const { localParticipant } = useLocalParticipant();
const [isAlwaysListening, setIsAlwaysListening] = React.useState(false);
const toggleAlwaysListening = () => {
const newValue = !isAlwaysListening;
setIsAlwaysListening(newValue);
handleAlwaysListeningToggle(newValue);
};
const handleAlwaysListeningToggle = (newValue: boolean) => {
if (newValue) {
console.log("SETTING VIDEO CONTEXT ON")
const data = new TextEncoder().encode("{VIDEO_CONTEXT_ON}")
localParticipant.publishData(data, {reliable: true, topic: "video_context"})
} else {
console.log("SETTING VIDEO CONTEXT OFF")
const data = new TextEncoder().encode("{VIDEO_CONTEXT_OFF}")
localParticipant.publishData(data, {reliable: true, topic: "video_context"})
}
}
React.useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
log.debug('Auto set screen share focus:', { newScreenShareTrack: screenShareTracks[0] });
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) =>
track.publication.trackSid ===
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid,
)
) {
log.debug('Auto clearing screen share focus.');
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
lastAutoFocusedScreenShareTrack.current = null;
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) =>
tr.participant.identity === focusTrack.participant.identity &&
tr.source === focusTrack.source,
);
if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
}
}
}, [
screenShareTracks
.map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`)
.join(),
focusTrack?.publication?.trackSid,
tracks,
]);
useWarnAboutMissingStyles();
return (
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
{!focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<ControlBar
controls={{ settings: !!SettingsComponent }}
/>
<button
className={`lk-button ${isAlwaysListening ? 'lk-button-active' : ''}`}
onClick={toggleAlwaysListening}
>
{isAlwaysListening ? 'Stop Listening' : 'Start Listening'}
</button>
</div>
{SettingsComponent && (
<div
className="lk-settings-menu-modal"
style={{ display: widgetState.showSettings ? 'block' : 'none' }}
>
<SettingsComponent />
</div>
)}
</LayoutContextProvider>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
);
}

@ -0,0 +1,20 @@
/**
* @internal
*/
export function isWeb(): boolean {
return typeof document !== 'undefined';
}
/**
* Mobile browser detection based on `navigator.userAgent` string.
* Defaults to returning `false` if not in a browser.
*
* @remarks
* This should only be used if feature detection or other methods do not work!
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
*/
export function isMobileBrowser(): boolean {
return isWeb() ? /Mobi/i.test(window.navigator.userAgent) : false;
}

@ -0,0 +1,56 @@
import {
setLogLevel as setClientSdkLogLevel,
setLogExtension as setClientSdkLogExtension,
LogLevel as LogLevelEnum,
} from 'livekit-client';
import loglevel from 'loglevel'
export const log = loglevel.getLogger('lk-components-js');
log.setDefaultLevel('WARN');
type LogLevel = Parameters<typeof setClientSdkLogLevel>[0];
type SetLogLevelOptions = {
liveKitClientLogLevel?: LogLevel;
};
/**
* Set the log level for both the `@livekit/components-react` package and the `@livekit-client` package.
* To set the `@livekit-client` log independently, use the `liveKitClientLogLevel` prop on the `options` object.
* @public
*/
export function setLogLevel(level: LogLevel, options: SetLogLevelOptions = {}): void {
log.setLevel(level);
setClientSdkLogLevel(options.liveKitClientLogLevel ?? level);
}
type LogExtension = (level: LogLevel, msg: string, context?: object) => void;
type SetLogExtensionOptions = {
liveKitClientLogExtension?: LogExtension;
};
/**
* Set the log extension for both the `@livekit/components-react` package and the `@livekit-client` package.
* To set the `@livekit-client` log extension, use the `liveKitClientLogExtension` prop on the `options` object.
* @public
*/
export function setLogExtension(extension: LogExtension, options: SetLogExtensionOptions = {}) {
const originalFactory = log.methodFactory;
log.methodFactory = (methodName, configLevel, loggerName) => {
const rawMethod = originalFactory(methodName, configLevel, loggerName);
const logLevel = LogLevelEnum[methodName];
const needLog = logLevel >= configLevel && logLevel < LogLevelEnum.silent;
return (msg, context?: [msg: string, context: object]) => {
if (context) rawMethod(msg, context);
else rawMethod(msg);
if (needLog) {
extension(logLevel, msg, context);
}
};
};
log.setLevel(log.getLevel()); // Be sure to call setLevel method in order to apply plugin
setClientSdkLogExtension(options.liveKitClientLogExtension ?? extension);
}

@ -0,0 +1,87 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import clsx from 'clsx';
/**
* Calls all functions in the order they were chained with the same arguments.
* @internal
*/
export function chain(...callbacks: any[]): (...args: any[]) => void {
return (...args: any[]) => {
for (const callback of callbacks) {
if (typeof callback === 'function') {
try {
callback(...args);
} catch (e) {
console.error(e);
}
}
}
};
}
interface Props {
[key: string]: any;
}
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? V : never;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
? I
: never;
/**
* Merges multiple props objects together. Event handlers are chained,
* classNames are combined, and ids are deduplicated - different ids
* will trigger a side-effect and re-render components hooked up with `useId`.
* For all other props, the last prop object overrides all previous ones.
* @param args - Multiple sets of props to merge together.
* @internal
*/
export function mergeProps<T extends Props[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
// Start with a base clone of the first argument. This is a lot faster than starting
// with an empty object and adding properties as we go.
const result: Props = { ...args[0] };
for (let i = 1; i < args.length; i++) {
const props = args[i];
for (const key in props) {
const a = result[key];
const b = props[key];
// Chain events
if (
typeof a === 'function' &&
typeof b === 'function' &&
// This is a lot faster than a regex.
key[0] === 'o' &&
key[1] === 'n' &&
key.charCodeAt(2) >= /* 'A' */ 65 &&
key.charCodeAt(2) <= /* 'Z' */ 90
) {
result[key] = chain(a, b);
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
} else if (
(key === 'className' || key === 'UNSAFE_className') &&
typeof a === 'string' &&
typeof b === 'string'
) {
result[key] = clsx(a, b);
} else {
result[key] = b !== undefined ? b : a;
}
}
}
return result as UnionToIntersection<TupleTypes<T>>;
}

@ -0,0 +1,73 @@
/**
* The TrackReference type is a logical grouping of participant publication and/or subscribed track.
*
*/
import type { Participant, Track, TrackPublication } from 'livekit-client';
// ## TrackReference Types
/** @public */
export type TrackReferencePlaceholder = {
participant: Participant;
publication?: never;
source: Track.Source;
};
/** @public */
export type TrackReference = {
participant: Participant;
publication: TrackPublication;
source: Track.Source;
};
/** @public */
export type TrackReferenceOrPlaceholder = TrackReference | TrackReferencePlaceholder;
// ### TrackReference Type Predicates
/** @internal */
export function isTrackReference(trackReference: unknown): trackReference is TrackReference {
if (typeof trackReference === 'undefined') {
return false;
}
return (
isTrackReferenceSubscribed(trackReference as TrackReference) ||
isTrackReferencePublished(trackReference as TrackReference)
);
}
function isTrackReferenceSubscribed(trackReference?: TrackReferenceOrPlaceholder): boolean {
if (!trackReference) {
return false;
}
return (
trackReference.hasOwnProperty('participant') &&
trackReference.hasOwnProperty('source') &&
trackReference.hasOwnProperty('track') &&
typeof trackReference.publication?.track !== 'undefined'
);
}
function isTrackReferencePublished(trackReference?: TrackReferenceOrPlaceholder): boolean {
if (!trackReference) {
return false;
}
return (
trackReference.hasOwnProperty('participant') &&
trackReference.hasOwnProperty('source') &&
trackReference.hasOwnProperty('publication') &&
typeof trackReference.publication !== 'undefined'
);
}
export function isTrackReferencePlaceholder(
trackReference?: TrackReferenceOrPlaceholder,
): trackReference is TrackReferencePlaceholder {
if (!trackReference) {
return false;
}
return (
trackReference.hasOwnProperty('participant') &&
trackReference.hasOwnProperty('source') &&
typeof trackReference.publication === 'undefined'
);
}

@ -0,0 +1,97 @@
import type { Track } from 'livekit-client';
import type { PinState } from './types';
import type { TrackReferenceOrPlaceholder } from './track-reference-types';
import { isTrackReference, isTrackReferencePlaceholder } from './track-reference-types';
/**
* Returns a id to identify the `TrackReference` or `TrackReferencePlaceholder` based on
* participant, track source and trackSid.
* @remarks
* The id pattern is: `${participantIdentity}_${trackSource}_${trackSid}` for `TrackReference`
* and `${participantIdentity}_${trackSource}_placeholder` for `TrackReferencePlaceholder`.
*/
export function getTrackReferenceId(trackReference: TrackReferenceOrPlaceholder | number) {
if (typeof trackReference === 'string' || typeof trackReference === 'number') {
return `${trackReference}`;
} else if (isTrackReferencePlaceholder(trackReference)) {
return `${trackReference.participant.identity}_${trackReference.source}_placeholder`;
} else if (isTrackReference(trackReference)) {
return `${trackReference.participant.identity}_${trackReference.publication.source}_${trackReference.publication.trackSid}`;
} else {
throw new Error(`Can't generate a id for the given track reference: ${trackReference}`);
}
}
export type TrackReferenceId = ReturnType<typeof getTrackReferenceId>;
/** Returns the Source of the TrackReference. */
export function getTrackReferenceSource(trackReference: TrackReferenceOrPlaceholder): Track.Source {
if (isTrackReference(trackReference)) {
return trackReference.publication.source;
} else {
return trackReference.source;
}
}
export function isEqualTrackRef(
a?: TrackReferenceOrPlaceholder,
b?: TrackReferenceOrPlaceholder,
): boolean {
if (a === undefined || b === undefined) {
return false;
}
if (isTrackReference(a) && isTrackReference(b)) {
return a.publication.trackSid === b.publication.trackSid;
} else {
return getTrackReferenceId(a) === getTrackReferenceId(b);
}
}
/**
* Check if the `TrackReference` is pinned.
*/
export function isTrackReferencePinned(
trackReference: TrackReferenceOrPlaceholder,
pinState: PinState | undefined,
): boolean {
if (typeof pinState === 'undefined') {
return false;
}
if (isTrackReference(trackReference)) {
return pinState.some(
(pinnedTrackReference) =>
pinnedTrackReference.participant.identity === trackReference.participant.identity &&
isTrackReference(pinnedTrackReference) &&
pinnedTrackReference.publication.trackSid === trackReference.publication.trackSid,
);
} else if (isTrackReferencePlaceholder(trackReference)) {
return pinState.some(
(pinnedTrackReference) =>
pinnedTrackReference.participant.identity === trackReference.participant.identity &&
isTrackReferencePlaceholder(pinnedTrackReference) &&
pinnedTrackReference.source === trackReference.source,
);
} else {
return false;
}
}
/**
* Check if the current `currentTrackRef` is the placeholder for next `nextTrackRef`.
* Based on the participant identity and the source.
* @internal
*/
export function isPlaceholderReplacement(
currentTrackRef: TrackReferenceOrPlaceholder,
nextTrackRef: TrackReferenceOrPlaceholder,
) {
// if (typeof nextTrackRef === 'number' || typeof currentTrackRef === 'number') {
// return false;
// }
return (
isTrackReferencePlaceholder(currentTrackRef) &&
isTrackReference(nextTrackRef) &&
nextTrackRef.participant.identity === currentTrackRef.participant.identity &&
nextTrackRef.source === currentTrackRef.source
);
}

@ -0,0 +1,90 @@
import type { Participant, ParticipantKind, Track, TrackPublication } from 'livekit-client';
import type { TrackReference, TrackReferenceOrPlaceholder } from './track-reference-types';
// ## PinState Type
/** @public */
export type PinState = TrackReferenceOrPlaceholder[];
export const PIN_DEFAULT_STATE: PinState = [];
// ## WidgetState Types
/** @public */
export type WidgetState = {
showChat: boolean;
unreadMessages: number;
showSettings?: boolean;
};
export const WIDGET_DEFAULT_STATE: WidgetState = {
showChat: false,
unreadMessages: 0,
showSettings: false,
};
// ## Track Source Types
export type TrackSourceWithOptions = { source: Track.Source; withPlaceholder: boolean };
export type SourcesArray = Track.Source[] | TrackSourceWithOptions[];
// ### Track Source Type Predicates
export function isSourceWitOptions(source: SourcesArray[number]): source is TrackSourceWithOptions {
return typeof source === 'object';
}
export function isSourcesWithOptions(sources: SourcesArray): sources is TrackSourceWithOptions[] {
return (
Array.isArray(sources) &&
(sources as TrackSourceWithOptions[]).filter(isSourceWitOptions).length > 0
);
}
// ## Loop Filter Types
export type TrackReferenceFilter = Parameters<TrackReferenceOrPlaceholder[]['filter']>['0'];
export type ParticipantFilter = Parameters<Participant[]['filter']>['0'];
// ## Other Types
/** @internal */
export interface ParticipantClickEvent {
participant: Participant;
track?: TrackPublication;
}
export type TrackSource<T extends Track.Source> = RequireAtLeastOne<
{ source: T; name: string; participant: Participant },
'name' | 'source'
>;
export type ParticipantTrackIdentifier = RequireAtLeastOne<
{ sources: Track.Source[]; name: string; kind: Track.Kind },
'sources' | 'name' | 'kind'
>;
/**
* @beta
*/
export type ParticipantIdentifier = RequireAtLeastOne<
{ kind: ParticipantKind; identity: string },
'identity' | 'kind'
>;
/**
* The TrackIdentifier type is used to select Tracks either based on
* - Track.Source and/or name of the track, e.g. `{source: Track.Source.Camera}` or `{name: "my-track"}`
* - TrackReference (participant and publication)
* @internal
*/
export type TrackIdentifier<T extends Track.Source = Track.Source> =
| TrackSource<T>
| TrackReference;
// ## Util Types
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
export type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
}[Keys];
export type AudioSource = Track.Source.Microphone | Track.Source.ScreenShareAudio;
export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;

@ -0,0 +1,11 @@
import * as React from 'react';
import { warnAboutMissingStyles } from './utils';
/**
* @internal
*/
export function useWarnAboutMissingStyles() {
React.useEffect(() => {
warnAboutMissingStyles();
}, []);
}

@ -0,0 +1,62 @@
import * as React from 'react';
import { mergeProps as mergePropsReactAria } from './mergeProps'
import { log } from './logger';
import clsx from 'clsx';
/** @internal */
export function isProp<U extends HTMLElement, T extends React.HTMLAttributes<U>>(
prop: T | undefined,
): prop is T {
return prop !== undefined;
}
/** @internal */
export function mergeProps<
U extends HTMLElement,
T extends Array<React.HTMLAttributes<U> | undefined>,
>(...props: T) {
return mergePropsReactAria(...props.filter(isProp));
}
/** @internal */
export function cloneSingleChild(
children: React.ReactNode | React.ReactNode[],
props?: Record<string, any>,
key?: any,
) {
return React.Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript
// error too.
if (React.isValidElement(child) && React.Children.only(children)) {
if (child.props.class) {
// make sure we retain classnames of both passed props and child
props ??= {};
props.class = clsx(child.props.class, props.class);
props.style = { ...child.props.style, ...props.style };
}
return React.cloneElement(child, { ...props, key });
}
return child;
});
}
/**
* @internal
*/
export function warnAboutMissingStyles(el?: HTMLElement) {
if (
typeof window !== 'undefined' &&
typeof process !== 'undefined' &&
// eslint-disable-next-line turbo/no-undeclared-env-vars
(process?.env?.NODE_ENV === 'dev' ||
// eslint-disable-next-line turbo/no-undeclared-env-vars
process?.env?.NODE_ENV === 'development')
) {
const target = el ?? document.querySelector('.lk-room-container');
if (target && !getComputedStyle(target).getPropertyValue('--lk-has-imported-styles')) {
log.warn(
"It looks like you're not using the `@livekit/components-styles package`. To render the UI with the default styling, please import it in your layout or page.",
);
}
}
}

@ -0,0 +1,79 @@
'use client';
import { formatChatMessageLinks, LiveKitRoom } from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
LogLevel,
Room,
RoomConnectOptions,
RoomOptions,
VideoPresets,
type VideoCodec,
} from 'livekit-client';
import { DebugMode } from '@/lib/Debug';
import { useMemo } from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { VideoConference } from '../components/VideoConference'
export function VideoConferenceClientImpl(props: {
liveKitUrl: string;
token: string;
codec: VideoCodec | undefined;
}) {
const worker =
typeof window !== 'undefined' &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const keyProvider = new ExternalE2EEKeyProvider();
const e2eePassphrase =
typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
const e2eeEnabled = !!(e2eePassphrase && worker);
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,
};
}, []);
const room = useMemo(() => new Room(roomOptions), []);
if (e2eeEnabled) {
keyProvider.setKey(e2eePassphrase);
room.setE2EEEnabled(true);
}
const connectOptions = useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
};
}, []);
return (
<LiveKitRoom
room={room}
token={props.token}
connectOptions={connectOptions}
serverUrl={props.liveKitUrl}
audio={true}
video={true}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
}
/>
<DebugMode logLevel={LogLevel.debug} />
</LiveKitRoom>
);
}

@ -0,0 +1,28 @@
import { videoCodecs } from 'livekit-client';
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
import { isVideoCodec } from '@/lib/types';
export default function CustomRoomConnection(props: {
searchParams: {
liveKitUrl?: string;
token?: string;
codec?: string;
};
}) {
const { liveKitUrl, token, codec } = 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} />
</main>
);
}

@ -0,0 +1,56 @@
import '../styles/globals.css';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import type { Metadata, Viewport } from 'next';
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>{children}</body>
</html>
);
}

@ -0,0 +1,201 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { Suspense, 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;
const router = useRouter();
function onTabSelected(index: number) {
const tab = index === 1 ? 'custom' : 'demo';
router.push(`/?tab=${tab}`);
}
let tabs = React.Children.map(props.children, (child, index) => {
return (
<button
className="lk-button"
onClick={() => {
if (onTabSelected) {
onTabSelected(index);
}
}}
aria-pressed={tabIndex === index}
>
{/* @ts-ignore */}
{child?.props.label}
</button>
);
});
return (
<div className={styles.tabContainer}>
<div className={styles.tabSelect}>{tabs}</div>
{/* @ts-ignore */}
{props.children[tabIndex]}
</div>
);
}
function DemoMeetingTab(props: { label: string }) {
const router = useRouter();
const [e2ee, setE2ee] = useState(false);
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
const startMeeting = () => {
if (e2ee) {
router.push(`/rooms/${generateRoomId()}#${encodePassphrase(sharedPassphrase)}`);
} else {
router.push(`/rooms/${generateRoomId()}`);
}
};
return (
<div className={styles.tabContent}>
<p style={{ margin: 0 }}>Try LiveKit Meet for free with our live demo project.</p>
<button style={{ marginTop: '1rem' }} className="lk-button" onClick={startMeeting}>
Start Meeting
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<input
id="use-e2ee"
type="checkbox"
checked={e2ee}
onChange={(ev) => setE2ee(ev.target.checked)}
></input>
<label htmlFor="use-e2ee">Enable end-to-end encryption</label>
</div>
{e2ee && (
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<label htmlFor="passphrase">Passphrase</label>
<input
id="passphrase"
type="password"
value={sharedPassphrase}
onChange={(ev) => setSharedPassphrase(ev.target.value)}
/>
</div>
)}
</div>
</div>
);
}
function CustomConnectionTab(props: { label: string }) {
const router = useRouter();
const [e2ee, setE2ee] = useState(false);
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
const onSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const serverUrl = formData.get('serverUrl');
const token = formData.get('token');
if (e2ee) {
router.push(
`/custom/?liveKitUrl=${serverUrl}&token=${token}#${encodePassphrase(sharedPassphrase)}`,
);
} else {
router.push(`/custom/?liveKitUrl=${serverUrl}&token=${token}`);
}
};
return (
<form className={styles.tabContent} onSubmit={onSubmit}>
<p style={{ marginTop: 0 }}>
Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server.
</p>
<input
id="serverUrl"
name="serverUrl"
type="url"
placeholder="LiveKit Server URL: wss://*.livekit.cloud"
required
/>
<textarea
id="token"
name="token"
placeholder="Token"
required
rows={5}
style={{ padding: '1px 2px', fontSize: 'inherit', lineHeight: 'inherit' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<input
id="use-e2ee"
type="checkbox"
checked={e2ee}
onChange={(ev) => setE2ee(ev.target.checked)}
></input>
<label htmlFor="use-e2ee">Enable end-to-end encryption</label>
</div>
{e2ee && (
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<label htmlFor="passphrase">Passphrase</label>
<input
id="passphrase"
type="password"
value={sharedPassphrase}
onChange={(ev) => setSharedPassphrase(ev.target.value)}
/>
</div>
)}
</div>
<hr
style={{ width: '100%', borderColor: 'rgba(255, 255, 255, 0.15)', marginBlock: '1rem' }}
/>
<button
style={{ paddingInline: '1.25rem', width: '100%' }}
className="lk-button"
type="submit"
>
Connect
</button>
</form>
);
}
export default function Page() {
return (
<>
<main className={styles.main} data-lk-theme="default">
<div className="header">
<img src="/images/livekit-meet-home.svg" alt="LiveKit Meet" width="360" height="45" />
<h2>
Open source video conferencing app built on{' '}
<a href="https://github.com/livekit/components-js?ref=meet" rel="noopener">
LiveKit&nbsp;Components
</a>
,{' '}
<a href="https://livekit.io/cloud?ref=meet" rel="noopener">
LiveKit&nbsp;Cloud
</a>{' '}
and Next.js.
</h2>
</div>
<Suspense fallback="Loading">
<Tabs>
<DemoMeetingTab label="Demo" />
<CustomConnectionTab label="Custom" />
</Tabs>
</Suspense>
</main>
<footer data-lk-theme="default">
Hosted on{' '}
<a href="https://livekit.io/cloud?ref=meet" rel="noopener">
LiveKit Cloud
</a>
. Source code on{' '}
<a href="https://github.com/livekit/meet?ref=meet" rel="noopener">
GitHub
</a>
.
</footer>
</>
);
}

@ -0,0 +1,203 @@
'use client';
import { decodePassphrase } from '@/lib/client-utils';
import { DebugMode } from '@/lib/Debug';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { ConnectionDetails } from '@/lib/types';
import {
formatChatMessageLinks,
LiveKitRoom,
LocalUserChoices,
PreJoin,
VideoConference,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
VideoCodec,
VideoPresets,
Room,
DeviceUnsupportedError,
RoomConnectOptions,
} from 'livekit-client';
import { useRouter } from 'next/navigation';
import React from 'react';
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 e2eePassphrase =
typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1));
const worker =
typeof window !== 'undefined' &&
e2eePassphrase &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const e2eeEnabled = !!(e2eePassphrase && worker);
const keyProvider = new ExternalE2EEKeyProvider();
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
const roomOptions = React.useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
return {
videoCaptureDefaults: {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
},
publishDefaults: {
dtx: false,
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
},
audioCaptureDefaults: {
deviceId: props.userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
};
}, [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,
};
}, []);
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}`,
);
}, []);
return (
<>
<LiveKitRoom
connect={e2eeSetupComplete}
room={room}
token={props.connectionDetails.participantToken}
serverUrl={props.connectionDetails.serverUrl}
connectOptions={connectOptions}
video={props.userChoices.videoEnabled}
audio={props.userChoices.audioEnabled}
onDisconnected={handleOnLeave}
onEncryptionError={handleEncryptionError}
onError={handleError}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
<DebugMode />
<RecordingIndicator />
</LiveKitRoom>
</>
);
}

@ -0,0 +1,26 @@
import * as React from 'react';
import { PageClientImpl } from './PageClientImpl';
import { isVideoCodec } from '@/lib/types';
export default function Page({
params,
searchParams,
}: {
params: { roomName: string };
searchParams: {
// FIXME: We should not allow values for regions if in playground mode.
region?: string;
hq?: string;
codec?: string;
};
}) {
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} />
);
}

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
productionBrowserSourceMaps: true,
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;
},
};
module.exports = nextConfig;

@ -0,0 +1,37 @@
{
"name": "livekit-meet",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@datadog/browser-logs": "^5.23.3",
"@livekit/components-react": "2.6.9",
"@livekit/components-styles": "1.1.4",
"@livekit/krisp-noise-filter": "0.2.13",
"clsx": "^2.1.1",
"livekit-client": "2.7.3",
"livekit-server-sdk": "2.9.3",
"loglevel": "^1.9.2",
"next": "14.2.18",
"react": "18.3.1",
"react-dom": "18.3.1",
"tinykeys": "^3.0.0"
},
"devDependencies": {
"@types/node": "22.9.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"eslint": "9.14.0",
"eslint-config-next": "14.2.18",
"source-map-loader": "^5.0.0",
"typescript": "5.6.3"
},
"engines": {
"node": ">=18"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="961" height="121" fill="none"><path fill="#fff" d="M20.2 0H0v118.1h73.4V101H20.2V0ZM106.9 53.8H87.2V118H107V53.8ZM164.6 115.8l-25-81.5H120l26.2 83.8H183l26.2-83.8h-19.8l-24.8 81.5ZM257.8 32.5c-25.4 0-41.6 18-41.6 43.7 0 25.5 15.7 43.8 41.6 43.8 19.8 0 34-8.7 39.4-26.6h-20c-3 8.1-8.4 13-19.2 13-12 0-20.3-8.3-21.9-24.5h62.5c.3-2 .5-4.1.5-6.2 0-26.1-16.3-43.2-41.3-43.2Zm-21.5 35.9c2-15 10-22.2 21.5-22.2 12.1 0 20.3 8.8 21.2 22.2h-42.7ZM413.8 0h-25.5l-49.2 54V0h-20.3v118.1h20.3V58.4l54.3 59.7h25.9L362.5 56l51.3-56ZM447.7 34.3H428v64.4h19.7V34.3ZM87.2 34.3H67.6v19.5h19.6V34.3ZM467.3 98.7h-19.6v19.4h19.6V98.7ZM525.9 98.7h-19.6v19.4h19.6V98.7ZM525.9 53.8V34.3h-19.6V0h-19.7v34.3H467v19.5h19.6v44.9h19.7v-45h19.6Z"/><path fill="#FF6352" d="M589.8 119V.4h-10.7V119h10.7Zm53.9 0L602.3.4H591L632.4 119h11.3Zm12.3 0L697.3.4h-11.2L644.7 119H656Zm53.2 0V.4h-10.6V119h10.6Zm99.4-42.9c0-25.6-16.4-41.8-38.4-41.8-23 0-38.7 17.5-38.7 43.2 0 25.9 15.6 43.2 39.2 43.2 18 0 31.3-8.4 36.2-26h-10.6c-3.6 11.4-11.7 18.2-25.6 18.2-16.4 0-27.8-11.8-28.7-32.7h66.3c.1-1.8.3-2.7.3-4.1Zm-38.4-34c16 0 26.8 12.8 27.8 30.1H742c1.7-18.9 12.4-30.1 28.1-30.1Zm130.4 34c0-25.6-16.4-41.8-38.4-41.8-23 0-38.7 17.5-38.7 43.2 0 25.9 15.6 43.2 39.2 43.2 18 0 31.3-8.4 36.2-26h-10.6c-3.6 11.4-11.7 18.2-25.6 18.2-16.4 0-27.8-11.8-28.7-32.7h66.3c.1-1.8.3-2.7.3-4.1Zm-38.4-34c16 0 26.9 12.8 27.8 30.1H834c1.8-18.9 12.4-30.1 28.1-30.1Zm88.3 69c-8.7 0-13.5-3.5-13.5-13.2V44h22.9v-8h-23V16.4h-10.2V36H908v8h18.6v53.9c0 14.4 9.3 21.1 22.9 21.1H960v-8h-9.5Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><path fill="#000" fill-rule="evenodd" d="M0 0h512v512H0zm288 224h-64v64h64v64H160V96H96v320h192v-64h64v64h64v-64h-64v-64h-64v-64h64v-64h64V96h-64v64h-64z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 259 B

@ -0,0 +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
}
]
}

@ -0,0 +1,16 @@
.overlay {
position: absolute;
top: 0;
background: rgba(0, 0, 0, 0.6);
padding: 1rem;
max-height: min(100%, 100vh);
overflow-y: auto;
}
.detailsSection {
padding-left: 1rem;
}
.detailsSection > div {
padding-left: 1rem;
}

@ -0,0 +1,40 @@
.main {
position: relative;
display: grid;
gap: 1rem;
justify-content: center;
place-content: center;
justify-items: center;
overflow: auto;
flex-grow: 1;
}
.tabContainer {
width: 100%;
max-width: 500px;
padding-inline: 2rem;
}
.tabSelect {
display: flex;
justify-content: stretch;
gap: 0.125rem;
padding: 0.125rem;
margin: 0 auto 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
}
.tabSelect > * {
width: 100%;
}
.tabContent {
display: flex;
justify-content: center;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
}

@ -0,0 +1,23 @@
.settingsCloseButton {
position: absolute;
right: var(--lk-grid-gap);
bottom: var(--lk-grid-gap);
}
.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);
}

@ -0,0 +1,67 @@
* {
box-sizing: border-box;
}
html {
color-scheme: dark;
background-color: #111;
}
html,
body {
overflow: hidden;
width: 100%;
height: 100%;
margin: 0px;
}
body {
display: flex;
flex-direction: column;
}
.header {
max-width: 500px;
padding-inline: 2rem;
}
.header > img {
display: block;
margin: auto;
max-width: 100%;
}
.header > h2 {
font-family: 'TWK Everett', sans-serif;
font-style: normal;
font-weight: 400;
font-size: 1.25rem;
line-height: 144%;
text-align: center;
color: rgba(255, 255, 255, 0.6);
}
footer {
width: 100%;
padding: 1.5rem 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.6);
background-color: var(--lk-bg);
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
footer a,
h2 a {
color: #ff6352;
text-decoration-color: #a33529;
text-underline-offset: 0.125em;
}
footer a:hover,
h2 a {
text-decoration-color: #ff6352;
}
h2 a {
text-decoration: none;
}

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