@ -0,0 +1,30 @@
|
|||||||
|
# 1. Copy this file and rename it to .env.local
|
||||||
|
# 2. Update the enviroment variables below.
|
||||||
|
|
||||||
|
# REQUIRED SETTINGS
|
||||||
|
# #################
|
||||||
|
# If you are using LiveKit Cloud, the API key and secret can be generated from the Cloud Dashboard.
|
||||||
|
LIVEKIT_API_KEY=
|
||||||
|
LIVEKIT_API_SECRET=
|
||||||
|
# URL pointing to the LiveKit server. (example: `wss://my-livekit-project.livekit.cloud`)
|
||||||
|
LIVEKIT_URL=http://localhost:10101
|
||||||
|
|
||||||
|
|
||||||
|
# OPTIONAL SETTINGS
|
||||||
|
# #################
|
||||||
|
# Recording
|
||||||
|
# S3_KEY_ID=
|
||||||
|
# S3_KEY_SECRET=
|
||||||
|
# S3_ENDPOINT=
|
||||||
|
# S3_BUCKET=
|
||||||
|
# S3_REGION=
|
||||||
|
|
||||||
|
# PUBLIC
|
||||||
|
# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters.
|
||||||
|
# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true
|
||||||
|
# NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record
|
||||||
|
|
||||||
|
# Optional, to pipe logs to datadog
|
||||||
|
# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token
|
||||||
|
# NEXT_PUBLIC_DATADOG_SITE=datadog-site
|
||||||
|
|
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
After Width: | Height: | Size: 832 B |
After Width: | Height: | Size: 100 KiB |
After Width: | Height: | Size: 3.0 KiB |
@ -0,0 +1,33 @@
|
|||||||
|
name: Sync main to sandbox-production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history so we can force push
|
||||||
|
|
||||||
|
- name: Set up Git
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'github-actions[bot]'
|
||||||
|
git config --global user.email 'github-actions[bot]@livekit.io'
|
||||||
|
|
||||||
|
- name: Sync to sandbox-production
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git checkout sandbox-production || git checkout -b sandbox-production
|
||||||
|
git merge --strategy-option theirs main
|
||||||
|
git push origin sandbox-production
|
@ -0,0 +1,38 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
@ -0,0 +1,3 @@
|
|||||||
|
.github/
|
||||||
|
.next/
|
||||||
|
node_modules/
|
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
@ -0,0 +1,16 @@
|
|||||||
|
# LiveKit Meet Client
|
||||||
|
|
||||||
|
This is a clone of the LiveKit Meet open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://livekit.io/cloud), and Next.js. Used as a simple web interface to the 01 with screenshare and camera functionality. Can be run using a fully local model, STT, and TTS.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run the following command in the software directory to open a Meet instance.
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry run 01 --server livekit --meet
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Ensure that you're in the meet directory. Then run `pnpm install` to install all dependencies. You're now all set to get up and running!
|
@ -0,0 +1,81 @@
|
|||||||
|
import { randomString } from '@/lib/client-utils';
|
||||||
|
import { ConnectionDetails } from '@/lib/types';
|
||||||
|
import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API_KEY = process.env.LIVEKIT_API_KEY;
|
||||||
|
const API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||||
|
const LIVEKIT_URL = process.env.LIVEKIT_URL;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Parse query parameters
|
||||||
|
const roomName = request.nextUrl.searchParams.get('roomName');
|
||||||
|
const participantName = request.nextUrl.searchParams.get('participantName');
|
||||||
|
const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
|
||||||
|
const region = request.nextUrl.searchParams.get('region');
|
||||||
|
const livekitServerUrl = region ? getLiveKitURL(region) : LIVEKIT_URL;
|
||||||
|
if (livekitServerUrl === undefined) {
|
||||||
|
throw new Error('Invalid region');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof roomName !== 'string') {
|
||||||
|
return new NextResponse('Missing required query parameter: roomName', { status: 400 });
|
||||||
|
}
|
||||||
|
if (participantName === null) {
|
||||||
|
return new NextResponse('Missing required query parameter: participantName', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate participant token
|
||||||
|
const participantToken = await createParticipantToken(
|
||||||
|
{
|
||||||
|
identity: `${participantName}__${randomString(4)}`,
|
||||||
|
name: participantName,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
roomName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return connection details
|
||||||
|
const data: ConnectionDetails = {
|
||||||
|
serverUrl: livekitServerUrl,
|
||||||
|
roomName: roomName,
|
||||||
|
participantToken: participantToken,
|
||||||
|
participantName: participantName,
|
||||||
|
};
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new NextResponse(error.message, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
|
||||||
|
const at = new AccessToken(API_KEY, API_SECRET, userInfo);
|
||||||
|
at.ttl = '5m';
|
||||||
|
const grant: VideoGrant = {
|
||||||
|
room: roomName,
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canPublishData: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
};
|
||||||
|
at.addGrant(grant);
|
||||||
|
return at.toJwt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the LiveKit server URL for the given region.
|
||||||
|
*/
|
||||||
|
function getLiveKitURL(region: string | null): string {
|
||||||
|
let targetKey = 'LIVEKIT_URL';
|
||||||
|
if (region) {
|
||||||
|
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
|
||||||
|
}
|
||||||
|
const url = process.env[targetKey];
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`${targetKey} is not defined`);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const roomName = req.nextUrl.searchParams.get('roomName');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAUTION:
|
||||||
|
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
|
||||||
|
* to start/stop recordings for that room.
|
||||||
|
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (roomName === null) {
|
||||||
|
return new NextResponse('Missing roomName parameter', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
LIVEKIT_API_KEY,
|
||||||
|
LIVEKIT_API_SECRET,
|
||||||
|
LIVEKIT_URL,
|
||||||
|
S3_KEY_ID,
|
||||||
|
S3_KEY_SECRET,
|
||||||
|
S3_BUCKET,
|
||||||
|
S3_ENDPOINT,
|
||||||
|
S3_REGION,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const hostURL = new URL(LIVEKIT_URL!);
|
||||||
|
hostURL.protocol = 'https:';
|
||||||
|
|
||||||
|
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||||
|
|
||||||
|
const existingEgresses = await egressClient.listEgress({ roomName });
|
||||||
|
if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) {
|
||||||
|
return new NextResponse('Meeting is already being recorded', { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileOutput = new EncodedFileOutput({
|
||||||
|
filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`,
|
||||||
|
output: {
|
||||||
|
case: 's3',
|
||||||
|
value: new S3Upload({
|
||||||
|
endpoint: S3_ENDPOINT,
|
||||||
|
accessKey: S3_KEY_ID,
|
||||||
|
secret: S3_KEY_SECRET,
|
||||||
|
region: S3_REGION,
|
||||||
|
bucket: S3_BUCKET,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await egressClient.startRoomCompositeEgress(
|
||||||
|
roomName,
|
||||||
|
{
|
||||||
|
file: fileOutput,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: 'speaker',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new NextResponse(error.message, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { EgressClient } from 'livekit-server-sdk';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const roomName = req.nextUrl.searchParams.get('roomName');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAUTION:
|
||||||
|
* for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
|
||||||
|
* to start/stop recordings for that room.
|
||||||
|
* DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (roomName === null) {
|
||||||
|
return new NextResponse('Missing roomName parameter', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env;
|
||||||
|
|
||||||
|
const hostURL = new URL(LIVEKIT_URL!);
|
||||||
|
hostURL.protocol = 'https:';
|
||||||
|
|
||||||
|
const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||||
|
const activeEgresses = (await egressClient.listEgress({ roomName })).filter(
|
||||||
|
(info) => info.status < 2,
|
||||||
|
);
|
||||||
|
if (activeEgresses.length === 0) {
|
||||||
|
return new NextResponse('No active recording found', { status: 404 });
|
||||||
|
}
|
||||||
|
await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId)));
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new NextResponse(error.message, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,210 @@
|
|||||||
|
import type {
|
||||||
|
MessageDecoder,
|
||||||
|
MessageEncoder,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
WidgetState,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { isTrackReference } from '@livekit/components-react';
|
||||||
|
import { log } from './logger';
|
||||||
|
import { isWeb } from './detectMobileBrowser';
|
||||||
|
import { isEqualTrackRef } from './track-reference';
|
||||||
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { MessageFormatter } from '@livekit/components-react';
|
||||||
|
import {
|
||||||
|
CarouselLayout,
|
||||||
|
ConnectionStateToast,
|
||||||
|
FocusLayout,
|
||||||
|
FocusLayoutContainer,
|
||||||
|
GridLayout,
|
||||||
|
LayoutContextProvider,
|
||||||
|
ParticipantTile,
|
||||||
|
RoomAudioRenderer,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { useCreateLayoutContext } from '@livekit/components-react';
|
||||||
|
import { usePinnedTracks, useTracks } from '@livekit/components-react';
|
||||||
|
import { ControlBar } from '@livekit/components-react';
|
||||||
|
import { useWarnAboutMissingStyles } from './useWarnAboutMissingStyles';
|
||||||
|
import { useLocalParticipant } from '@livekit/components-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
chatMessageFormatter?: MessageFormatter;
|
||||||
|
chatMessageEncoder?: MessageEncoder;
|
||||||
|
chatMessageDecoder?: MessageDecoder;
|
||||||
|
/** @alpha */
|
||||||
|
SettingsComponent?: React.ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
|
||||||
|
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
||||||
|
* of participants, basic non-persistent chat, screen sharing, and more.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* The component is implemented with other LiveKit components like `FocusContextProvider`,
|
||||||
|
* `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
|
||||||
|
* You can use these components as a starting point for your own custom video conferencing application.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* <VideoConference />
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function VideoConference({
|
||||||
|
chatMessageFormatter,
|
||||||
|
chatMessageDecoder,
|
||||||
|
chatMessageEncoder,
|
||||||
|
SettingsComponent,
|
||||||
|
...props
|
||||||
|
}: VideoConferenceProps) {
|
||||||
|
const [widgetState, setWidgetState] = React.useState<WidgetState>({
|
||||||
|
showChat: false,
|
||||||
|
unreadMessages: 0,
|
||||||
|
showSettings: false,
|
||||||
|
});
|
||||||
|
const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null);
|
||||||
|
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const widgetUpdate = (state: WidgetState) => {
|
||||||
|
log.debug('updating widget state', state);
|
||||||
|
setWidgetState(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutContext = useCreateLayoutContext();
|
||||||
|
|
||||||
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare);
|
||||||
|
|
||||||
|
const focusTrack = usePinnedTracks(layoutContext)?.[0];
|
||||||
|
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
|
||||||
|
|
||||||
|
const { localParticipant } = useLocalParticipant();
|
||||||
|
|
||||||
|
const [isAlwaysListening, setIsAlwaysListening] = React.useState(false);
|
||||||
|
|
||||||
|
const toggleAlwaysListening = () => {
|
||||||
|
const newValue = !isAlwaysListening;
|
||||||
|
setIsAlwaysListening(newValue);
|
||||||
|
handleAlwaysListeningToggle(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlwaysListeningToggle = (newValue: boolean) => {
|
||||||
|
|
||||||
|
if (newValue) {
|
||||||
|
console.log("SETTING VIDEO CONTEXT ON")
|
||||||
|
const data = new TextEncoder().encode("{VIDEO_CONTEXT_ON}")
|
||||||
|
localParticipant.publishData(data, {reliable: true, topic: "video_context"})
|
||||||
|
} else {
|
||||||
|
console.log("SETTING VIDEO CONTEXT OFF")
|
||||||
|
const data = new TextEncoder().encode("{VIDEO_CONTEXT_OFF}")
|
||||||
|
localParticipant.publishData(data, {reliable: true, topic: "video_context"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
|
if (
|
||||||
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
|
) {
|
||||||
|
log.debug('Auto set screen share focus:', { newScreenShareTrack: screenShareTracks[0] });
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
|
||||||
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
|
||||||
|
} else if (
|
||||||
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
|
!screenShareTracks.some(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid ===
|
||||||
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.');
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null;
|
||||||
|
}
|
||||||
|
if (focusTrack && !isTrackReference(focusTrack)) {
|
||||||
|
const updatedFocusTrack = tracks.find(
|
||||||
|
(tr) =>
|
||||||
|
tr.participant.identity === focusTrack.participant.identity &&
|
||||||
|
tr.source === focusTrack.source,
|
||||||
|
);
|
||||||
|
if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
screenShareTracks
|
||||||
|
.map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`)
|
||||||
|
.join(),
|
||||||
|
focusTrack?.publication?.trackSid,
|
||||||
|
tracks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useWarnAboutMissingStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-video-conference" {...props}>
|
||||||
|
{isWeb() && (
|
||||||
|
<LayoutContextProvider
|
||||||
|
value={layoutContext}
|
||||||
|
// onPinChange={handleFocusStateChange}
|
||||||
|
onWidgetChange={widgetUpdate}
|
||||||
|
>
|
||||||
|
<div className="lk-video-conference-inner">
|
||||||
|
{!focusTrack ? (
|
||||||
|
<div className="lk-grid-layout-wrapper">
|
||||||
|
<GridLayout tracks={tracks}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper">
|
||||||
|
<FocusLayoutContainer>
|
||||||
|
<CarouselLayout tracks={carouselTracks}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ControlBar
|
||||||
|
controls={{ settings: !!SettingsComponent }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`lk-button ${isAlwaysListening ? 'lk-button-active' : ''}`}
|
||||||
|
onClick={toggleAlwaysListening}
|
||||||
|
>
|
||||||
|
{isAlwaysListening ? 'Stop Listening' : 'Start Listening'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{SettingsComponent && (
|
||||||
|
<div
|
||||||
|
className="lk-settings-menu-modal"
|
||||||
|
style={{ display: widgetState.showSettings ? 'block' : 'none' }}
|
||||||
|
>
|
||||||
|
<SettingsComponent />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LayoutContextProvider>
|
||||||
|
)}
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
<ConnectionStateToast />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function isWeb(): boolean {
|
||||||
|
return typeof document !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile browser detection based on `navigator.userAgent` string.
|
||||||
|
* Defaults to returning `false` if not in a browser.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This should only be used if feature detection or other methods do not work!
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
|
||||||
|
*/
|
||||||
|
export function isMobileBrowser(): boolean {
|
||||||
|
return isWeb() ? /Mobi/i.test(window.navigator.userAgent) : false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
setLogLevel as setClientSdkLogLevel,
|
||||||
|
setLogExtension as setClientSdkLogExtension,
|
||||||
|
LogLevel as LogLevelEnum,
|
||||||
|
} from 'livekit-client';
|
||||||
|
import loglevel from 'loglevel'
|
||||||
|
|
||||||
|
export const log = loglevel.getLogger('lk-components-js');
|
||||||
|
log.setDefaultLevel('WARN');
|
||||||
|
|
||||||
|
type LogLevel = Parameters<typeof setClientSdkLogLevel>[0];
|
||||||
|
type SetLogLevelOptions = {
|
||||||
|
liveKitClientLogLevel?: LogLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the log level for both the `@livekit/components-react` package and the `@livekit-client` package.
|
||||||
|
* To set the `@livekit-client` log independently, use the `liveKitClientLogLevel` prop on the `options` object.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function setLogLevel(level: LogLevel, options: SetLogLevelOptions = {}): void {
|
||||||
|
log.setLevel(level);
|
||||||
|
setClientSdkLogLevel(options.liveKitClientLogLevel ?? level);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogExtension = (level: LogLevel, msg: string, context?: object) => void;
|
||||||
|
type SetLogExtensionOptions = {
|
||||||
|
liveKitClientLogExtension?: LogExtension;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the log extension for both the `@livekit/components-react` package and the `@livekit-client` package.
|
||||||
|
* To set the `@livekit-client` log extension, use the `liveKitClientLogExtension` prop on the `options` object.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function setLogExtension(extension: LogExtension, options: SetLogExtensionOptions = {}) {
|
||||||
|
const originalFactory = log.methodFactory;
|
||||||
|
|
||||||
|
log.methodFactory = (methodName, configLevel, loggerName) => {
|
||||||
|
const rawMethod = originalFactory(methodName, configLevel, loggerName);
|
||||||
|
|
||||||
|
const logLevel = LogLevelEnum[methodName];
|
||||||
|
const needLog = logLevel >= configLevel && logLevel < LogLevelEnum.silent;
|
||||||
|
|
||||||
|
return (msg, context?: [msg: string, context: object]) => {
|
||||||
|
if (context) rawMethod(msg, context);
|
||||||
|
else rawMethod(msg);
|
||||||
|
if (needLog) {
|
||||||
|
extension(logLevel, msg, context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
log.setLevel(log.getLevel()); // Be sure to call setLevel method in order to apply plugin
|
||||||
|
setClientSdkLogExtension(options.liveKitClientLogExtension ?? extension);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Adobe. All rights reserved.
|
||||||
|
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||||
|
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||||
|
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||||
|
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||||
|
* governing permissions and limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls all functions in the order they were chained with the same arguments.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function chain(...callbacks: any[]): (...args: any[]) => void {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
|
||||||
|
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? V : never;
|
||||||
|
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
|
||||||
|
? I
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges multiple props objects together. Event handlers are chained,
|
||||||
|
* classNames are combined, and ids are deduplicated - different ids
|
||||||
|
* will trigger a side-effect and re-render components hooked up with `useId`.
|
||||||
|
* For all other props, the last prop object overrides all previous ones.
|
||||||
|
* @param args - Multiple sets of props to merge together.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function mergeProps<T extends Props[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
|
||||||
|
// Start with a base clone of the first argument. This is a lot faster than starting
|
||||||
|
// with an empty object and adding properties as we go.
|
||||||
|
const result: Props = { ...args[0] };
|
||||||
|
for (let i = 1; i < args.length; i++) {
|
||||||
|
const props = args[i];
|
||||||
|
for (const key in props) {
|
||||||
|
const a = result[key];
|
||||||
|
const b = props[key];
|
||||||
|
|
||||||
|
// Chain events
|
||||||
|
if (
|
||||||
|
typeof a === 'function' &&
|
||||||
|
typeof b === 'function' &&
|
||||||
|
// This is a lot faster than a regex.
|
||||||
|
key[0] === 'o' &&
|
||||||
|
key[1] === 'n' &&
|
||||||
|
key.charCodeAt(2) >= /* 'A' */ 65 &&
|
||||||
|
key.charCodeAt(2) <= /* 'Z' */ 90
|
||||||
|
) {
|
||||||
|
result[key] = chain(a, b);
|
||||||
|
|
||||||
|
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
|
||||||
|
} else if (
|
||||||
|
(key === 'className' || key === 'UNSAFE_className') &&
|
||||||
|
typeof a === 'string' &&
|
||||||
|
typeof b === 'string'
|
||||||
|
) {
|
||||||
|
result[key] = clsx(a, b);
|
||||||
|
} else {
|
||||||
|
result[key] = b !== undefined ? b : a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as UnionToIntersection<TupleTypes<T>>;
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* The TrackReference type is a logical grouping of participant publication and/or subscribed track.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Participant, Track, TrackPublication } from 'livekit-client';
|
||||||
|
// ## TrackReference Types
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type TrackReferencePlaceholder = {
|
||||||
|
participant: Participant;
|
||||||
|
publication?: never;
|
||||||
|
source: Track.Source;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type TrackReference = {
|
||||||
|
participant: Participant;
|
||||||
|
publication: TrackPublication;
|
||||||
|
source: Track.Source;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type TrackReferenceOrPlaceholder = TrackReference | TrackReferencePlaceholder;
|
||||||
|
|
||||||
|
// ### TrackReference Type Predicates
|
||||||
|
/** @internal */
|
||||||
|
export function isTrackReference(trackReference: unknown): trackReference is TrackReference {
|
||||||
|
if (typeof trackReference === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
isTrackReferenceSubscribed(trackReference as TrackReference) ||
|
||||||
|
isTrackReferencePublished(trackReference as TrackReference)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackReferenceSubscribed(trackReference?: TrackReferenceOrPlaceholder): boolean {
|
||||||
|
if (!trackReference) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
trackReference.hasOwnProperty('participant') &&
|
||||||
|
trackReference.hasOwnProperty('source') &&
|
||||||
|
trackReference.hasOwnProperty('track') &&
|
||||||
|
typeof trackReference.publication?.track !== 'undefined'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackReferencePublished(trackReference?: TrackReferenceOrPlaceholder): boolean {
|
||||||
|
if (!trackReference) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
trackReference.hasOwnProperty('participant') &&
|
||||||
|
trackReference.hasOwnProperty('source') &&
|
||||||
|
trackReference.hasOwnProperty('publication') &&
|
||||||
|
typeof trackReference.publication !== 'undefined'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrackReferencePlaceholder(
|
||||||
|
trackReference?: TrackReferenceOrPlaceholder,
|
||||||
|
): trackReference is TrackReferencePlaceholder {
|
||||||
|
if (!trackReference) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
trackReference.hasOwnProperty('participant') &&
|
||||||
|
trackReference.hasOwnProperty('source') &&
|
||||||
|
typeof trackReference.publication === 'undefined'
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,97 @@
|
|||||||
|
import type { Track } from 'livekit-client';
|
||||||
|
import type { PinState } from './types';
|
||||||
|
import type { TrackReferenceOrPlaceholder } from './track-reference-types';
|
||||||
|
import { isTrackReference, isTrackReferencePlaceholder } from './track-reference-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a id to identify the `TrackReference` or `TrackReferencePlaceholder` based on
|
||||||
|
* participant, track source and trackSid.
|
||||||
|
* @remarks
|
||||||
|
* The id pattern is: `${participantIdentity}_${trackSource}_${trackSid}` for `TrackReference`
|
||||||
|
* and `${participantIdentity}_${trackSource}_placeholder` for `TrackReferencePlaceholder`.
|
||||||
|
*/
|
||||||
|
export function getTrackReferenceId(trackReference: TrackReferenceOrPlaceholder | number) {
|
||||||
|
if (typeof trackReference === 'string' || typeof trackReference === 'number') {
|
||||||
|
return `${trackReference}`;
|
||||||
|
} else if (isTrackReferencePlaceholder(trackReference)) {
|
||||||
|
return `${trackReference.participant.identity}_${trackReference.source}_placeholder`;
|
||||||
|
} else if (isTrackReference(trackReference)) {
|
||||||
|
return `${trackReference.participant.identity}_${trackReference.publication.source}_${trackReference.publication.trackSid}`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Can't generate a id for the given track reference: ${trackReference}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackReferenceId = ReturnType<typeof getTrackReferenceId>;
|
||||||
|
|
||||||
|
/** Returns the Source of the TrackReference. */
|
||||||
|
export function getTrackReferenceSource(trackReference: TrackReferenceOrPlaceholder): Track.Source {
|
||||||
|
if (isTrackReference(trackReference)) {
|
||||||
|
return trackReference.publication.source;
|
||||||
|
} else {
|
||||||
|
return trackReference.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEqualTrackRef(
|
||||||
|
a?: TrackReferenceOrPlaceholder,
|
||||||
|
b?: TrackReferenceOrPlaceholder,
|
||||||
|
): boolean {
|
||||||
|
if (a === undefined || b === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isTrackReference(a) && isTrackReference(b)) {
|
||||||
|
return a.publication.trackSid === b.publication.trackSid;
|
||||||
|
} else {
|
||||||
|
return getTrackReferenceId(a) === getTrackReferenceId(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the `TrackReference` is pinned.
|
||||||
|
*/
|
||||||
|
export function isTrackReferencePinned(
|
||||||
|
trackReference: TrackReferenceOrPlaceholder,
|
||||||
|
pinState: PinState | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (typeof pinState === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isTrackReference(trackReference)) {
|
||||||
|
return pinState.some(
|
||||||
|
(pinnedTrackReference) =>
|
||||||
|
pinnedTrackReference.participant.identity === trackReference.participant.identity &&
|
||||||
|
isTrackReference(pinnedTrackReference) &&
|
||||||
|
pinnedTrackReference.publication.trackSid === trackReference.publication.trackSid,
|
||||||
|
);
|
||||||
|
} else if (isTrackReferencePlaceholder(trackReference)) {
|
||||||
|
return pinState.some(
|
||||||
|
(pinnedTrackReference) =>
|
||||||
|
pinnedTrackReference.participant.identity === trackReference.participant.identity &&
|
||||||
|
isTrackReferencePlaceholder(pinnedTrackReference) &&
|
||||||
|
pinnedTrackReference.source === trackReference.source,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current `currentTrackRef` is the placeholder for next `nextTrackRef`.
|
||||||
|
* Based on the participant identity and the source.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function isPlaceholderReplacement(
|
||||||
|
currentTrackRef: TrackReferenceOrPlaceholder,
|
||||||
|
nextTrackRef: TrackReferenceOrPlaceholder,
|
||||||
|
) {
|
||||||
|
// if (typeof nextTrackRef === 'number' || typeof currentTrackRef === 'number') {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
return (
|
||||||
|
isTrackReferencePlaceholder(currentTrackRef) &&
|
||||||
|
isTrackReference(nextTrackRef) &&
|
||||||
|
nextTrackRef.participant.identity === currentTrackRef.participant.identity &&
|
||||||
|
nextTrackRef.source === currentTrackRef.source
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
import type { Participant, ParticipantKind, Track, TrackPublication } from 'livekit-client';
|
||||||
|
import type { TrackReference, TrackReferenceOrPlaceholder } from './track-reference-types';
|
||||||
|
|
||||||
|
// ## PinState Type
|
||||||
|
/** @public */
|
||||||
|
export type PinState = TrackReferenceOrPlaceholder[];
|
||||||
|
export const PIN_DEFAULT_STATE: PinState = [];
|
||||||
|
|
||||||
|
// ## WidgetState Types
|
||||||
|
/** @public */
|
||||||
|
export type WidgetState = {
|
||||||
|
showChat: boolean;
|
||||||
|
unreadMessages: number;
|
||||||
|
showSettings?: boolean;
|
||||||
|
};
|
||||||
|
export const WIDGET_DEFAULT_STATE: WidgetState = {
|
||||||
|
showChat: false,
|
||||||
|
unreadMessages: 0,
|
||||||
|
showSettings: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ## Track Source Types
|
||||||
|
export type TrackSourceWithOptions = { source: Track.Source; withPlaceholder: boolean };
|
||||||
|
|
||||||
|
export type SourcesArray = Track.Source[] | TrackSourceWithOptions[];
|
||||||
|
|
||||||
|
// ### Track Source Type Predicates
|
||||||
|
export function isSourceWitOptions(source: SourcesArray[number]): source is TrackSourceWithOptions {
|
||||||
|
return typeof source === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSourcesWithOptions(sources: SourcesArray): sources is TrackSourceWithOptions[] {
|
||||||
|
return (
|
||||||
|
Array.isArray(sources) &&
|
||||||
|
(sources as TrackSourceWithOptions[]).filter(isSourceWitOptions).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ## Loop Filter Types
|
||||||
|
export type TrackReferenceFilter = Parameters<TrackReferenceOrPlaceholder[]['filter']>['0'];
|
||||||
|
export type ParticipantFilter = Parameters<Participant[]['filter']>['0'];
|
||||||
|
|
||||||
|
// ## Other Types
|
||||||
|
/** @internal */
|
||||||
|
export interface ParticipantClickEvent {
|
||||||
|
participant: Participant;
|
||||||
|
track?: TrackPublication;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackSource<T extends Track.Source> = RequireAtLeastOne<
|
||||||
|
{ source: T; name: string; participant: Participant },
|
||||||
|
'name' | 'source'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ParticipantTrackIdentifier = RequireAtLeastOne<
|
||||||
|
{ sources: Track.Source[]; name: string; kind: Track.Kind },
|
||||||
|
'sources' | 'name' | 'kind'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
*/
|
||||||
|
export type ParticipantIdentifier = RequireAtLeastOne<
|
||||||
|
{ kind: ParticipantKind; identity: string },
|
||||||
|
'identity' | 'kind'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TrackIdentifier type is used to select Tracks either based on
|
||||||
|
* - Track.Source and/or name of the track, e.g. `{source: Track.Source.Camera}` or `{name: "my-track"}`
|
||||||
|
* - TrackReference (participant and publication)
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type TrackIdentifier<T extends Track.Source = Track.Source> =
|
||||||
|
| TrackSource<T>
|
||||||
|
| TrackReference;
|
||||||
|
|
||||||
|
// ## Util Types
|
||||||
|
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
|
||||||
|
{
|
||||||
|
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
|
||||||
|
}[Keys];
|
||||||
|
|
||||||
|
export type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
|
||||||
|
{
|
||||||
|
[K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
|
||||||
|
}[Keys];
|
||||||
|
|
||||||
|
export type AudioSource = Track.Source.Microphone | Track.Source.ScreenShareAudio;
|
||||||
|
export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;
|
@ -0,0 +1,11 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { warnAboutMissingStyles } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useWarnAboutMissingStyles() {
|
||||||
|
React.useEffect(() => {
|
||||||
|
warnAboutMissingStyles();
|
||||||
|
}, []);
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { mergeProps as mergePropsReactAria } from './mergeProps'
|
||||||
|
import { log } from './logger';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export function isProp<U extends HTMLElement, T extends React.HTMLAttributes<U>>(
|
||||||
|
prop: T | undefined,
|
||||||
|
): prop is T {
|
||||||
|
return prop !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export function mergeProps<
|
||||||
|
U extends HTMLElement,
|
||||||
|
T extends Array<React.HTMLAttributes<U> | undefined>,
|
||||||
|
>(...props: T) {
|
||||||
|
return mergePropsReactAria(...props.filter(isProp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export function cloneSingleChild(
|
||||||
|
children: React.ReactNode | React.ReactNode[],
|
||||||
|
props?: Record<string, any>,
|
||||||
|
key?: any,
|
||||||
|
) {
|
||||||
|
return React.Children.map(children, (child) => {
|
||||||
|
// Checking isValidElement is the safe way and avoids a typescript
|
||||||
|
// error too.
|
||||||
|
if (React.isValidElement(child) && React.Children.only(children)) {
|
||||||
|
if (child.props.class) {
|
||||||
|
// make sure we retain classnames of both passed props and child
|
||||||
|
props ??= {};
|
||||||
|
props.class = clsx(child.props.class, props.class);
|
||||||
|
props.style = { ...child.props.style, ...props.style };
|
||||||
|
}
|
||||||
|
return React.cloneElement(child, { ...props, key });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function warnAboutMissingStyles(el?: HTMLElement) {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof process !== 'undefined' &&
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
|
(process?.env?.NODE_ENV === 'dev' ||
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
|
process?.env?.NODE_ENV === 'development')
|
||||||
|
) {
|
||||||
|
const target = el ?? document.querySelector('.lk-room-container');
|
||||||
|
if (target && !getComputedStyle(target).getPropertyValue('--lk-has-imported-styles')) {
|
||||||
|
log.warn(
|
||||||
|
"It looks like you're not using the `@livekit/components-styles package`. To render the UI with the default styling, please import it in your layout or page.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { formatChatMessageLinks, LiveKitRoom } from '@livekit/components-react';
|
||||||
|
import {
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
LogLevel,
|
||||||
|
Room,
|
||||||
|
RoomConnectOptions,
|
||||||
|
RoomOptions,
|
||||||
|
VideoPresets,
|
||||||
|
type VideoCodec,
|
||||||
|
} from 'livekit-client';
|
||||||
|
import { DebugMode } from '@/lib/Debug';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { decodePassphrase } from '@/lib/client-utils';
|
||||||
|
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||||
|
import { VideoConference } from '../components/VideoConference'
|
||||||
|
|
||||||
|
export function VideoConferenceClientImpl(props: {
|
||||||
|
liveKitUrl: string;
|
||||||
|
token: string;
|
||||||
|
codec: VideoCodec | undefined;
|
||||||
|
}) {
|
||||||
|
const worker =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
|
||||||
|
const keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
|
||||||
|
const e2eePassphrase =
|
||||||
|
typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
|
||||||
|
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||||
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
|
return {
|
||||||
|
publishDefaults: {
|
||||||
|
videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
|
||||||
|
red: !e2eeEnabled,
|
||||||
|
videoCodec: props.codec,
|
||||||
|
},
|
||||||
|
adaptiveStream: { pixelDensity: 'screen' },
|
||||||
|
dynacast: true,
|
||||||
|
e2ee: e2eeEnabled
|
||||||
|
? {
|
||||||
|
keyProvider,
|
||||||
|
worker,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const room = useMemo(() => new Room(roomOptions), []);
|
||||||
|
if (e2eeEnabled) {
|
||||||
|
keyProvider.setKey(e2eePassphrase);
|
||||||
|
room.setE2EEEnabled(true);
|
||||||
|
}
|
||||||
|
const connectOptions = useMemo((): RoomConnectOptions => {
|
||||||
|
return {
|
||||||
|
autoSubscribe: true,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LiveKitRoom
|
||||||
|
room={room}
|
||||||
|
token={props.token}
|
||||||
|
connectOptions={connectOptions}
|
||||||
|
serverUrl={props.liveKitUrl}
|
||||||
|
audio={true}
|
||||||
|
video={true}
|
||||||
|
>
|
||||||
|
<VideoConference
|
||||||
|
chatMessageFormatter={formatChatMessageLinks}
|
||||||
|
SettingsComponent={
|
||||||
|
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DebugMode logLevel={LogLevel.debug} />
|
||||||
|
</LiveKitRoom>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { videoCodecs } from 'livekit-client';
|
||||||
|
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
|
||||||
|
import { isVideoCodec } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function CustomRoomConnection(props: {
|
||||||
|
searchParams: {
|
||||||
|
liveKitUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
codec?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { liveKitUrl, token, codec } = props.searchParams;
|
||||||
|
if (typeof liveKitUrl !== 'string') {
|
||||||
|
return <h2>Missing LiveKit URL</h2>;
|
||||||
|
}
|
||||||
|
if (typeof token !== 'string') {
|
||||||
|
return <h2>Missing LiveKit token</h2>;
|
||||||
|
}
|
||||||
|
if (codec !== undefined && !isVideoCodec(codec)) {
|
||||||
|
return <h2>Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].</h2>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main data-lk-theme="default" style={{ height: '100%' }}>
|
||||||
|
<VideoConferenceClientImpl liveKitUrl={liveKitUrl} token={token} codec={codec} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
import '../styles/globals.css';
|
||||||
|
import '@livekit/components-styles';
|
||||||
|
import '@livekit/components-styles/prefabs';
|
||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'LiveKit Meet | Conference app build with LiveKit open source',
|
||||||
|
template: '%s',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications.',
|
||||||
|
twitter: {
|
||||||
|
creator: '@livekitted',
|
||||||
|
site: '@livekitted',
|
||||||
|
card: 'summary_large_image',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
url: 'https://meet.livekit.io',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png',
|
||||||
|
width: 2000,
|
||||||
|
height: 1000,
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
siteName: 'LiveKit Meet',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: {
|
||||||
|
rel: 'icon',
|
||||||
|
url: '/favicon.ico',
|
||||||
|
},
|
||||||
|
apple: [
|
||||||
|
{
|
||||||
|
rel: 'apple-touch-icon',
|
||||||
|
url: '/images/livekit-apple-touch.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
},
|
||||||
|
{ rel: 'mask-icon', url: '/images/livekit-safari-pinned-tab.svg', color: '#070707' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#070707',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import React, { Suspense, useState } from 'react';
|
||||||
|
import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils';
|
||||||
|
import styles from '../styles/Home.module.css';
|
||||||
|
|
||||||
|
function Tabs(props: React.PropsWithChildren<{}>) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
function onTabSelected(index: number) {
|
||||||
|
const tab = index === 1 ? 'custom' : 'demo';
|
||||||
|
router.push(`/?tab=${tab}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabs = React.Children.map(props.children, (child, index) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="lk-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (onTabSelected) {
|
||||||
|
onTabSelected(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-pressed={tabIndex === index}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{child?.props.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tabContainer}>
|
||||||
|
<div className={styles.tabSelect}>{tabs}</div>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{props.children[tabIndex]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemoMeetingTab(props: { label: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [e2ee, setE2ee] = useState(false);
|
||||||
|
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
|
||||||
|
const startMeeting = () => {
|
||||||
|
if (e2ee) {
|
||||||
|
router.push(`/rooms/${generateRoomId()}#${encodePassphrase(sharedPassphrase)}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/rooms/${generateRoomId()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<p style={{ margin: 0 }}>Try LiveKit Meet for free with our live demo project.</p>
|
||||||
|
<button style={{ marginTop: '1rem' }} className="lk-button" onClick={startMeeting}>
|
||||||
|
Start Meeting
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
|
||||||
|
<input
|
||||||
|
id="use-e2ee"
|
||||||
|
type="checkbox"
|
||||||
|
checked={e2ee}
|
||||||
|
onChange={(ev) => setE2ee(ev.target.checked)}
|
||||||
|
></input>
|
||||||
|
<label htmlFor="use-e2ee">Enable end-to-end encryption</label>
|
||||||
|
</div>
|
||||||
|
{e2ee && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
|
||||||
|
<label htmlFor="passphrase">Passphrase</label>
|
||||||
|
<input
|
||||||
|
id="passphrase"
|
||||||
|
type="password"
|
||||||
|
value={sharedPassphrase}
|
||||||
|
onChange={(ev) => setSharedPassphrase(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomConnectionTab(props: { label: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [e2ee, setE2ee] = useState(false);
|
||||||
|
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
|
||||||
|
|
||||||
|
const onSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.target as HTMLFormElement);
|
||||||
|
const serverUrl = formData.get('serverUrl');
|
||||||
|
const token = formData.get('token');
|
||||||
|
if (e2ee) {
|
||||||
|
router.push(
|
||||||
|
`/custom/?liveKitUrl=${serverUrl}&token=${token}#${encodePassphrase(sharedPassphrase)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(`/custom/?liveKitUrl=${serverUrl}&token=${token}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form className={styles.tabContent} onSubmit={onSubmit}>
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="serverUrl"
|
||||||
|
name="serverUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="LiveKit Server URL: wss://*.livekit.cloud"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
id="token"
|
||||||
|
name="token"
|
||||||
|
placeholder="Token"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
style={{ padding: '1px 2px', fontSize: 'inherit', lineHeight: 'inherit' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
|
||||||
|
<input
|
||||||
|
id="use-e2ee"
|
||||||
|
type="checkbox"
|
||||||
|
checked={e2ee}
|
||||||
|
onChange={(ev) => setE2ee(ev.target.checked)}
|
||||||
|
></input>
|
||||||
|
<label htmlFor="use-e2ee">Enable end-to-end encryption</label>
|
||||||
|
</div>
|
||||||
|
{e2ee && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
|
||||||
|
<label htmlFor="passphrase">Passphrase</label>
|
||||||
|
<input
|
||||||
|
id="passphrase"
|
||||||
|
type="password"
|
||||||
|
value={sharedPassphrase}
|
||||||
|
onChange={(ev) => setSharedPassphrase(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr
|
||||||
|
style={{ width: '100%', borderColor: 'rgba(255, 255, 255, 0.15)', marginBlock: '1rem' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
style={{ paddingInline: '1.25rem', width: '100%' }}
|
||||||
|
className="lk-button"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className={styles.main} data-lk-theme="default">
|
||||||
|
<div className="header">
|
||||||
|
<img src="/images/livekit-meet-home.svg" alt="LiveKit Meet" width="360" height="45" />
|
||||||
|
<h2>
|
||||||
|
Open source video conferencing app built on{' '}
|
||||||
|
<a href="https://github.com/livekit/components-js?ref=meet" rel="noopener">
|
||||||
|
LiveKit Components
|
||||||
|
</a>
|
||||||
|
,{' '}
|
||||||
|
<a href="https://livekit.io/cloud?ref=meet" rel="noopener">
|
||||||
|
LiveKit Cloud
|
||||||
|
</a>{' '}
|
||||||
|
and Next.js.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback="Loading">
|
||||||
|
<Tabs>
|
||||||
|
<DemoMeetingTab label="Demo" />
|
||||||
|
<CustomConnectionTab label="Custom" />
|
||||||
|
</Tabs>
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
<footer data-lk-theme="default">
|
||||||
|
Hosted on{' '}
|
||||||
|
<a href="https://livekit.io/cloud?ref=meet" rel="noopener">
|
||||||
|
LiveKit Cloud
|
||||||
|
</a>
|
||||||
|
. Source code on{' '}
|
||||||
|
<a href="https://github.com/livekit/meet?ref=meet" rel="noopener">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { decodePassphrase } from '@/lib/client-utils';
|
||||||
|
import { DebugMode } from '@/lib/Debug';
|
||||||
|
import { RecordingIndicator } from '@/lib/RecordingIndicator';
|
||||||
|
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||||
|
import { ConnectionDetails } from '@/lib/types';
|
||||||
|
import {
|
||||||
|
formatChatMessageLinks,
|
||||||
|
LiveKitRoom,
|
||||||
|
LocalUserChoices,
|
||||||
|
PreJoin,
|
||||||
|
VideoConference,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import {
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
RoomOptions,
|
||||||
|
VideoCodec,
|
||||||
|
VideoPresets,
|
||||||
|
Room,
|
||||||
|
DeviceUnsupportedError,
|
||||||
|
RoomConnectOptions,
|
||||||
|
} from 'livekit-client';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CONN_DETAILS_ENDPOINT =
|
||||||
|
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
|
||||||
|
const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true';
|
||||||
|
|
||||||
|
export function PageClientImpl(props: {
|
||||||
|
roomName: string;
|
||||||
|
region?: string;
|
||||||
|
hq: boolean;
|
||||||
|
codec: VideoCodec;
|
||||||
|
}) {
|
||||||
|
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const preJoinDefaults = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
videoEnabled: true,
|
||||||
|
audioEnabled: true,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const [connectionDetails, setConnectionDetails] = React.useState<ConnectionDetails | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => {
|
||||||
|
setPreJoinChoices(values);
|
||||||
|
const url = new URL(CONN_DETAILS_ENDPOINT, window.location.origin);
|
||||||
|
url.searchParams.append('roomName', props.roomName);
|
||||||
|
url.searchParams.append('participantName', values.username);
|
||||||
|
if (props.region) {
|
||||||
|
url.searchParams.append('region', props.region);
|
||||||
|
}
|
||||||
|
const connectionDetailsResp = await fetch(url.toString());
|
||||||
|
const connectionDetailsData = await connectionDetailsResp.json();
|
||||||
|
setConnectionDetails(connectionDetailsData);
|
||||||
|
}, []);
|
||||||
|
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main data-lk-theme="default" style={{ height: '100%' }}>
|
||||||
|
{connectionDetails === undefined || preJoinChoices === undefined ? (
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
||||||
|
<PreJoin
|
||||||
|
defaults={preJoinDefaults}
|
||||||
|
onSubmit={handlePreJoinSubmit}
|
||||||
|
onError={handlePreJoinError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<VideoConferenceComponent
|
||||||
|
connectionDetails={connectionDetails}
|
||||||
|
userChoices={preJoinChoices}
|
||||||
|
options={{ codec: props.codec, hq: props.hq }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoConferenceComponent(props: {
|
||||||
|
userChoices: LocalUserChoices;
|
||||||
|
connectionDetails: ConnectionDetails;
|
||||||
|
options: {
|
||||||
|
hq: boolean;
|
||||||
|
codec: VideoCodec;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const e2eePassphrase =
|
||||||
|
typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1));
|
||||||
|
|
||||||
|
const worker =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
e2eePassphrase &&
|
||||||
|
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
|
||||||
|
const e2eeEnabled = !!(e2eePassphrase && worker);
|
||||||
|
const keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
|
||||||
|
|
||||||
|
const roomOptions = React.useMemo((): RoomOptions => {
|
||||||
|
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
|
||||||
|
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
|
||||||
|
videoCodec = undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
deviceId: props.userChoices.videoDeviceId ?? undefined,
|
||||||
|
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
|
||||||
|
},
|
||||||
|
publishDefaults: {
|
||||||
|
dtx: false,
|
||||||
|
videoSimulcastLayers: props.options.hq
|
||||||
|
? [VideoPresets.h1080, VideoPresets.h720]
|
||||||
|
: [VideoPresets.h540, VideoPresets.h216],
|
||||||
|
red: !e2eeEnabled,
|
||||||
|
videoCodec,
|
||||||
|
},
|
||||||
|
audioCaptureDefaults: {
|
||||||
|
deviceId: props.userChoices.audioDeviceId ?? undefined,
|
||||||
|
},
|
||||||
|
adaptiveStream: { pixelDensity: 'screen' },
|
||||||
|
dynacast: true,
|
||||||
|
e2ee: e2eeEnabled
|
||||||
|
? {
|
||||||
|
keyProvider,
|
||||||
|
worker,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}, [props.userChoices, props.options.hq, props.options.codec]);
|
||||||
|
|
||||||
|
const room = React.useMemo(() => new Room(roomOptions), []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (e2eeEnabled) {
|
||||||
|
keyProvider
|
||||||
|
.setKey(decodePassphrase(e2eePassphrase))
|
||||||
|
.then(() => {
|
||||||
|
room.setE2EEEnabled(true).catch((e) => {
|
||||||
|
if (e instanceof DeviceUnsupportedError) {
|
||||||
|
alert(
|
||||||
|
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => setE2eeSetupComplete(true));
|
||||||
|
} else {
|
||||||
|
setE2eeSetupComplete(true);
|
||||||
|
}
|
||||||
|
}, [e2eeEnabled, room, e2eePassphrase]);
|
||||||
|
|
||||||
|
const connectOptions = React.useMemo((): RoomConnectOptions => {
|
||||||
|
return {
|
||||||
|
autoSubscribe: true,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
|
||||||
|
const handleError = React.useCallback((error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
alert(`Encountered an unexpected error, check the console logs for details: ${error.message}`);
|
||||||
|
}, []);
|
||||||
|
const handleEncryptionError = React.useCallback((error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
alert(
|
||||||
|
`Encountered an unexpected encryption error, check the console logs for details: ${error.message}`,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LiveKitRoom
|
||||||
|
connect={e2eeSetupComplete}
|
||||||
|
room={room}
|
||||||
|
token={props.connectionDetails.participantToken}
|
||||||
|
serverUrl={props.connectionDetails.serverUrl}
|
||||||
|
connectOptions={connectOptions}
|
||||||
|
video={props.userChoices.videoEnabled}
|
||||||
|
audio={props.userChoices.audioEnabled}
|
||||||
|
onDisconnected={handleOnLeave}
|
||||||
|
onEncryptionError={handleEncryptionError}
|
||||||
|
onError={handleError}
|
||||||
|
>
|
||||||
|
<VideoConference
|
||||||
|
chatMessageFormatter={formatChatMessageLinks}
|
||||||
|
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
|
||||||
|
/>
|
||||||
|
<DebugMode />
|
||||||
|
<RecordingIndicator />
|
||||||
|
</LiveKitRoom>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { PageClientImpl } from './PageClientImpl';
|
||||||
|
import { isVideoCodec } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function Page({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: { roomName: string };
|
||||||
|
searchParams: {
|
||||||
|
// FIXME: We should not allow values for regions if in playground mode.
|
||||||
|
region?: string;
|
||||||
|
hq?: string;
|
||||||
|
codec?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const codec =
|
||||||
|
typeof searchParams.codec === 'string' && isVideoCodec(searchParams.codec)
|
||||||
|
? searchParams.codec
|
||||||
|
: 'vp9';
|
||||||
|
const hq = searchParams.hq === 'true' ? true : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageClientImpl roomName={params.roomName} region={searchParams.region} hq={hq} codec={codec} />
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: false,
|
||||||
|
productionBrowserSourceMaps: true,
|
||||||
|
webpack: (config, { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }) => {
|
||||||
|
// Important: return the modified config
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.mjs$/,
|
||||||
|
enforce: 'pre',
|
||||||
|
use: ['source-map-loader'],
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "livekit-meet",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@datadog/browser-logs": "^5.23.3",
|
||||||
|
"@livekit/components-react": "2.6.9",
|
||||||
|
"@livekit/components-styles": "1.1.4",
|
||||||
|
"@livekit/krisp-noise-filter": "0.2.13",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"livekit-client": "2.7.3",
|
||||||
|
"livekit-server-sdk": "2.9.3",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"next": "14.2.18",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"tinykeys": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.9.0",
|
||||||
|
"@types/react": "18.3.12",
|
||||||
|
"@types/react-dom": "18.3.1",
|
||||||
|
"eslint": "9.14.0",
|
||||||
|
"eslint-config-next": "14.2.18",
|
||||||
|
"source-map-loader": "^5.0.0",
|
||||||
|
"typescript": "5.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 323 B |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 259 B |
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": ["config:base"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"schedule": "before 6am on the first day of the month",
|
||||||
|
"matchDepTypes": ["devDependencies"],
|
||||||
|
"matchUpdateTypes": ["patch", "minor"],
|
||||||
|
"groupName": "devDependencies (non-major)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchSourceUrlPrefixes": ["https://github.com/livekit/"],
|
||||||
|
"rangeStrategy": "replace",
|
||||||
|
"groupName": "LiveKit dependencies (non-major)",
|
||||||
|
"automerge": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: min(100%, 100vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsSection {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsSection > div {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
.main {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContainer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabSelect {
|
||||||
|
display: flex;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.125rem;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabSelect > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
.settingsCloseButton {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--lk-grid-gap);
|
||||||
|
bottom: var(--lk-grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs > .tab {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 3px solid;
|
||||||
|
border-color: var(--bg5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs > .tab[aria-pressed='true'] {
|
||||||
|
border-color: var(--lk-accent-bg);
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
max-width: 500px;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header > img {
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header > h2 {
|
||||||
|
font-family: 'TWK Everett', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 144%;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
background-color: var(--lk-bg);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a,
|
||||||
|
h2 a {
|
||||||
|
color: #ff6352;
|
||||||
|
text-decoration-color: #a33529;
|
||||||
|
text-underline-offset: 0.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover,
|
||||||
|
h2 a {
|
||||||
|
text-decoration-color: #ff6352;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2020"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|