diff --git a/software/source/clients/meet/.env.example b/software/source/clients/meet/.env.example
new file mode 100644
index 0000000..0f7b5a5
--- /dev/null
+++ b/software/source/clients/meet/.env.example
@@ -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
+
diff --git a/software/source/clients/meet/.eslintrc.json b/software/source/clients/meet/.eslintrc.json
new file mode 100644
index 0000000..bffb357
--- /dev/null
+++ b/software/source/clients/meet/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/software/source/clients/meet/.github/assets/livekit-mark.png b/software/source/clients/meet/.github/assets/livekit-mark.png
new file mode 100644
index 0000000..e984d81
Binary files /dev/null and b/software/source/clients/meet/.github/assets/livekit-mark.png differ
diff --git a/software/source/clients/meet/.github/assets/livekit-meet.jpg b/software/source/clients/meet/.github/assets/livekit-meet.jpg
new file mode 100644
index 0000000..5325caa
Binary files /dev/null and b/software/source/clients/meet/.github/assets/livekit-meet.jpg differ
diff --git a/software/source/clients/meet/.github/assets/template-graphic.svg b/software/source/clients/meet/.github/assets/template-graphic.svg
new file mode 100644
index 0000000..24c0f64
--- /dev/null
+++ b/software/source/clients/meet/.github/assets/template-graphic.svg
@@ -0,0 +1,31 @@
+
diff --git a/software/source/clients/meet/.github/workflows/sync-to-production.yaml b/software/source/clients/meet/.github/workflows/sync-to-production.yaml
new file mode 100644
index 0000000..03acb19
--- /dev/null
+++ b/software/source/clients/meet/.github/workflows/sync-to-production.yaml
@@ -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
diff --git a/software/source/clients/meet/.gitignore b/software/source/clients/meet/.gitignore
new file mode 100644
index 0000000..7d093c3
--- /dev/null
+++ b/software/source/clients/meet/.gitignore
@@ -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
diff --git a/software/source/clients/meet/.prettierignore b/software/source/clients/meet/.prettierignore
new file mode 100644
index 0000000..fb6e24e
--- /dev/null
+++ b/software/source/clients/meet/.prettierignore
@@ -0,0 +1,3 @@
+.github/
+.next/
+node_modules/
\ No newline at end of file
diff --git a/software/source/clients/meet/.prettierrc b/software/source/clients/meet/.prettierrc
new file mode 100644
index 0000000..4148c21
--- /dev/null
+++ b/software/source/clients/meet/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "semi": true,
+ "tabWidth": 2,
+ "printWidth": 100
+}
diff --git a/software/source/clients/meet/LICENSE b/software/source/clients/meet/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/software/source/clients/meet/LICENSE
@@ -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.
diff --git a/software/source/clients/meet/README.md b/software/source/clients/meet/README.md
new file mode 100644
index 0000000..6f1b23d
--- /dev/null
+++ b/software/source/clients/meet/README.md
@@ -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!
diff --git a/software/source/clients/meet/app/api/connection-details/route.ts b/software/source/clients/meet/app/api/connection-details/route.ts
new file mode 100644
index 0000000..48d73e1
--- /dev/null
+++ b/software/source/clients/meet/app/api/connection-details/route.ts
@@ -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;
+}
diff --git a/software/source/clients/meet/app/api/record/start/route.ts b/software/source/clients/meet/app/api/record/start/route.ts
new file mode 100644
index 0000000..dfe88f5
--- /dev/null
+++ b/software/source/clients/meet/app/api/record/start/route.ts
@@ -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 });
+ }
+ }
+}
diff --git a/software/source/clients/meet/app/api/record/stop/route.ts b/software/source/clients/meet/app/api/record/stop/route.ts
new file mode 100644
index 0000000..e2630ac
--- /dev/null
+++ b/software/source/clients/meet/app/api/record/stop/route.ts
@@ -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 });
+ }
+ }
+}
diff --git a/software/source/clients/meet/app/components/VideoConference.tsx b/software/source/clients/meet/app/components/VideoConference.tsx
new file mode 100644
index 0000000..0e08755
--- /dev/null
+++ b/software/source/clients/meet/app/components/VideoConference.tsx
@@ -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 {
+ 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
+ *
+ *
+ *
+ * ```
+ * @public
+ */
+ export function VideoConference({
+ chatMessageFormatter,
+ chatMessageDecoder,
+ chatMessageEncoder,
+ SettingsComponent,
+ ...props
+ }: VideoConferenceProps) {
+ const [widgetState, setWidgetState] = React.useState({
+ showChat: false,
+ unreadMessages: 0,
+ showSettings: false,
+ });
+ const lastAutoFocusedScreenShareTrack = React.useRef(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 (
+
+ {isWeb() && (
+
+
+ {!focusTrack ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ {focusTrack && }
+
+
+ )}
+
+
+
+
+ {SettingsComponent && (
+
+
+
+ )}
+
+ )}
+
+
+
+ );
+ }
+
\ No newline at end of file
diff --git a/software/source/clients/meet/app/components/detectMobileBrowser.ts b/software/source/clients/meet/app/components/detectMobileBrowser.ts
new file mode 100644
index 0000000..f3ba40c
--- /dev/null
+++ b/software/source/clients/meet/app/components/detectMobileBrowser.ts
@@ -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;
+ }
+
\ No newline at end of file
diff --git a/software/source/clients/meet/app/components/logger.ts b/software/source/clients/meet/app/components/logger.ts
new file mode 100644
index 0000000..39c8f9d
--- /dev/null
+++ b/software/source/clients/meet/app/components/logger.ts
@@ -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[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);
+ }
+
\ No newline at end of file
diff --git a/software/source/clients/meet/app/components/mergeProps.ts b/software/source/clients/meet/app/components/mergeProps.ts
new file mode 100644
index 0000000..c1bee7c
--- /dev/null
+++ b/software/source/clients/meet/app/components/mergeProps.ts
@@ -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 = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? V : never;
+type UnionToIntersection = (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(...args: T): UnionToIntersection> {
+ // 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>;
+}
diff --git a/software/source/clients/meet/app/components/track-reference-types.ts b/software/source/clients/meet/app/components/track-reference-types.ts
new file mode 100644
index 0000000..94f6db5
--- /dev/null
+++ b/software/source/clients/meet/app/components/track-reference-types.ts
@@ -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'
+ );
+}
diff --git a/software/source/clients/meet/app/components/track-reference.ts b/software/source/clients/meet/app/components/track-reference.ts
new file mode 100644
index 0000000..ea7d28b
--- /dev/null
+++ b/software/source/clients/meet/app/components/track-reference.ts
@@ -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;
+
+/** 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
+ );
+}
diff --git a/software/source/clients/meet/app/components/types.ts b/software/source/clients/meet/app/components/types.ts
new file mode 100644
index 0000000..00d9e09
--- /dev/null
+++ b/software/source/clients/meet/app/components/types.ts
@@ -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['0'];
+export type ParticipantFilter = Parameters['0'];
+
+// ## Other Types
+/** @internal */
+export interface ParticipantClickEvent {
+ participant: Participant;
+ track?: TrackPublication;
+}
+
+export type TrackSource = 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 =
+ | TrackSource
+ | TrackReference;
+
+// ## Util Types
+type RequireAtLeastOne = Pick> &
+ {
+ [K in Keys]-?: Required> & Partial>>;
+ }[Keys];
+
+export type RequireOnlyOne = Pick> &
+ {
+ [K in Keys]-?: Required> & Partial, undefined>>;
+ }[Keys];
+
+export type AudioSource = Track.Source.Microphone | Track.Source.ScreenShareAudio;
+export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;
diff --git a/software/source/clients/meet/app/components/useWarnAboutMissingStyles.ts b/software/source/clients/meet/app/components/useWarnAboutMissingStyles.ts
new file mode 100644
index 0000000..623cbec
--- /dev/null
+++ b/software/source/clients/meet/app/components/useWarnAboutMissingStyles.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+import { warnAboutMissingStyles } from './utils';
+
+/**
+ * @internal
+ */
+export function useWarnAboutMissingStyles() {
+ React.useEffect(() => {
+ warnAboutMissingStyles();
+ }, []);
+}
diff --git a/software/source/clients/meet/app/components/utils.ts b/software/source/clients/meet/app/components/utils.ts
new file mode 100644
index 0000000..e1b2d6f
--- /dev/null
+++ b/software/source/clients/meet/app/components/utils.ts
@@ -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>(
+ prop: T | undefined,
+): prop is T {
+ return prop !== undefined;
+}
+
+/** @internal */
+export function mergeProps<
+ U extends HTMLElement,
+ T extends Array | undefined>,
+>(...props: T) {
+ return mergePropsReactAria(...props.filter(isProp));
+}
+
+/** @internal */
+export function cloneSingleChild(
+ children: React.ReactNode | React.ReactNode[],
+ props?: Record,
+ 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.",
+ );
+ }
+ }
+}
diff --git a/software/source/clients/meet/app/custom/VideoConferenceClientImpl.tsx b/software/source/clients/meet/app/custom/VideoConferenceClientImpl.tsx
new file mode 100644
index 0000000..4dbedc3
--- /dev/null
+++ b/software/source/clients/meet/app/custom/VideoConferenceClientImpl.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/software/source/clients/meet/app/custom/page.tsx b/software/source/clients/meet/app/custom/page.tsx
new file mode 100644
index 0000000..3b78a75
--- /dev/null
+++ b/software/source/clients/meet/app/custom/page.tsx
@@ -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