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

Missing LiveKit URL

; + } + if (typeof token !== 'string') { + return

Missing LiveKit token

; + } + if (codec !== undefined && !isVideoCodec(codec)) { + return

Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].

; + } + + return ( +
+ +
+ ); +} diff --git a/software/source/clients/meet/app/layout.tsx b/software/source/clients/meet/app/layout.tsx new file mode 100644 index 0000000..bf35374 --- /dev/null +++ b/software/source/clients/meet/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/software/source/clients/meet/app/page.tsx b/software/source/clients/meet/app/page.tsx new file mode 100644 index 0000000..d23d536 --- /dev/null +++ b/software/source/clients/meet/app/page.tsx @@ -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 ( + + ); + }); + + return ( +
+
{tabs}
+ {/* @ts-ignore */} + {props.children[tabIndex]} +
+ ); +} + +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 ( +
+

Try LiveKit Meet for free with our live demo project.

+ +
+
+ setE2ee(ev.target.checked)} + > + +
+ {e2ee && ( +
+ + setSharedPassphrase(ev.target.value)} + /> +
+ )} +
+
+ ); +} + +function CustomConnectionTab(props: { label: string }) { + const router = useRouter(); + + const [e2ee, setE2ee] = useState(false); + const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64)); + + const onSubmit: React.FormEventHandler = (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 ( +
+

+ Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server. +

+ +