replace hosted livekit meet with local meet link
| 
						 | 
				
			
			@ -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  | 
| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
<svg width="270" height="151" viewBox="0 0 270 151" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
<rect width="270" height="151" fill="#070707"/>
 | 
			
		||||
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" fill="#131313"/>
 | 
			
		||||
<rect x="8.5" y="8.5" width="192" height="134" rx="1.5" stroke="#1F1F1F"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M101.167 71.4998C101.167 69.6589 102.66 68.1665 104.501 68.1665C106.342 68.1665 107.834 69.6589 107.834 71.4998C107.834 73.3408 106.342 74.8332 104.501 74.8332C102.66 74.8332 101.167 73.3408 101.167 71.4998Z" fill="#666666"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.834 82.1665C97.834 78.4846 100.819 75.4998 104.501 75.4998C108.183 75.4998 111.167 78.4846 111.167 82.1665V82.8332H97.834V82.1665Z" fill="#666666"/>
 | 
			
		||||
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" fill="#131313"/>
 | 
			
		||||
<rect x="209.5" y="8.5" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
 | 
			
		||||
<g clip-path="url(#clip0_834_19648)">
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 23.8333C232.167 21.9924 233.66 20.5 235.501 20.5C237.342 20.5 238.834 21.9924 238.834 23.8333C238.834 25.6743 237.342 27.1667 235.501 27.1667C233.66 27.1667 232.167 25.6743 232.167 23.8333Z" fill="#666666"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 34.5C228.834 30.8181 231.819 27.8333 235.501 27.8333C239.183 27.8333 242.167 30.8181 242.167 34.5V35.1667H228.834V34.5Z" fill="#666666"/>
 | 
			
		||||
</g>
 | 
			
		||||
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" fill="#131313"/>
 | 
			
		||||
<rect x="209.5" y="56.1665" width="52" height="38.6667" rx="1.5" stroke="#CCCCCC"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 71.4998C232.167 69.6589 233.66 68.1665 235.501 68.1665C237.342 68.1665 238.834 69.6589 238.834 71.4998C238.834 73.3408 237.342 74.8332 235.501 74.8332C233.66 74.8332 232.167 73.3408 232.167 71.4998Z" fill="#CCCCCC"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 82.1665C228.834 78.4846 231.819 75.4998 235.501 75.4998C239.183 75.4998 242.167 78.4846 242.167 82.1665V82.8332H228.834V82.1665Z" fill="#CCCCCC"/>
 | 
			
		||||
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" fill="#131313"/>
 | 
			
		||||
<rect x="209.5" y="103.833" width="52" height="38.6667" rx="1.5" stroke="#1F1F1F"/>
 | 
			
		||||
<g clip-path="url(#clip1_834_19648)">
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M232.167 119.167C232.167 117.326 233.66 115.833 235.501 115.833C237.342 115.833 238.834 117.326 238.834 119.167C238.834 121.008 237.342 122.5 235.501 122.5C233.66 122.5 232.167 121.008 232.167 119.167Z" fill="#666666"/>
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M228.834 129.833C228.834 126.152 231.819 123.167 235.501 123.167C239.183 123.167 242.167 126.152 242.167 129.833V130.5H228.834V129.833Z" fill="#666666"/>
 | 
			
		||||
</g>
 | 
			
		||||
<defs>
 | 
			
		||||
<clipPath id="clip0_834_19648">
 | 
			
		||||
<rect width="16" height="16" fill="white" transform="translate(227.5 19.8335)"/>
 | 
			
		||||
</clipPath>
 | 
			
		||||
<clipPath id="clip1_834_19648">
 | 
			
		||||
<rect width="16" height="16" fill="white" transform="translate(227.5 115.167)"/>
 | 
			
		||||
</clipPath>
 | 
			
		||||
</defs>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.0 KiB  | 
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
name: Sync main to sandbox-production
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - main
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  contents: write
 | 
			
		||||
  pull-requests: write
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  sync:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout code
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0 # Fetch all history so we can force push
 | 
			
		||||
 | 
			
		||||
      - name: Set up Git
 | 
			
		||||
        run: |
 | 
			
		||||
          git config --global user.name 'github-actions[bot]'
 | 
			
		||||
          git config --global user.email 'github-actions[bot]@livekit.io'
 | 
			
		||||
 | 
			
		||||
      - name: Sync to sandbox-production
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
        run: |
 | 
			
		||||
          git checkout sandbox-production || git checkout -b sandbox-production
 | 
			
		||||
          git merge --strategy-option theirs main
 | 
			
		||||
          git push origin sandbox-production
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 | 
			
		||||
 | 
			
		||||
# dependencies
 | 
			
		||||
/node_modules
 | 
			
		||||
/.pnp
 | 
			
		||||
.pnp.js
 | 
			
		||||
 | 
			
		||||
# testing
 | 
			
		||||
/coverage
 | 
			
		||||
 | 
			
		||||
# next.js
 | 
			
		||||
/.next/
 | 
			
		||||
/out/
 | 
			
		||||
 | 
			
		||||
# production
 | 
			
		||||
/build
 | 
			
		||||
 | 
			
		||||
# misc
 | 
			
		||||
.DS_Store
 | 
			
		||||
*.pem
 | 
			
		||||
 | 
			
		||||
# debug
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
.pnpm-debug.log*
 | 
			
		||||
 | 
			
		||||
# local env files
 | 
			
		||||
.env.local
 | 
			
		||||
.env.development.local
 | 
			
		||||
.env.test.local
 | 
			
		||||
.env.production.local
 | 
			
		||||
 | 
			
		||||
# vercel
 | 
			
		||||
.vercel
 | 
			
		||||
 | 
			
		||||
# typescript
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
.github/
 | 
			
		||||
.next/
 | 
			
		||||
node_modules/
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
{
 | 
			
		||||
  "singleQuote": true,
 | 
			
		||||
  "trailingComma": "all",
 | 
			
		||||
  "semi": true,
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "printWidth": 100
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,202 @@
 | 
			
		|||
 | 
			
		||||
                                 Apache License
 | 
			
		||||
                           Version 2.0, January 2004
 | 
			
		||||
                        http://www.apache.org/licenses/
 | 
			
		||||
 | 
			
		||||
   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 | 
			
		||||
 | 
			
		||||
   1. Definitions.
 | 
			
		||||
 | 
			
		||||
      "License" shall mean the terms and conditions for use, reproduction,
 | 
			
		||||
      and distribution as defined by Sections 1 through 9 of this document.
 | 
			
		||||
 | 
			
		||||
      "Licensor" shall mean the copyright owner or entity authorized by
 | 
			
		||||
      the copyright owner that is granting the License.
 | 
			
		||||
 | 
			
		||||
      "Legal Entity" shall mean the union of the acting entity and all
 | 
			
		||||
      other entities that control, are controlled by, or are under common
 | 
			
		||||
      control with that entity. For the purposes of this definition,
 | 
			
		||||
      "control" means (i) the power, direct or indirect, to cause the
 | 
			
		||||
      direction or management of such entity, whether by contract or
 | 
			
		||||
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
 | 
			
		||||
      outstanding shares, or (iii) beneficial ownership of such entity.
 | 
			
		||||
 | 
			
		||||
      "You" (or "Your") shall mean an individual or Legal Entity
 | 
			
		||||
      exercising permissions granted by this License.
 | 
			
		||||
 | 
			
		||||
      "Source" form shall mean the preferred form for making modifications,
 | 
			
		||||
      including but not limited to software source code, documentation
 | 
			
		||||
      source, and configuration files.
 | 
			
		||||
 | 
			
		||||
      "Object" form shall mean any form resulting from mechanical
 | 
			
		||||
      transformation or translation of a Source form, including but
 | 
			
		||||
      not limited to compiled object code, generated documentation,
 | 
			
		||||
      and conversions to other media types.
 | 
			
		||||
 | 
			
		||||
      "Work" shall mean the work of authorship, whether in Source or
 | 
			
		||||
      Object form, made available under the License, as indicated by a
 | 
			
		||||
      copyright notice that is included in or attached to the work
 | 
			
		||||
      (an example is provided in the Appendix below).
 | 
			
		||||
 | 
			
		||||
      "Derivative Works" shall mean any work, whether in Source or Object
 | 
			
		||||
      form, that is based on (or derived from) the Work and for which the
 | 
			
		||||
      editorial revisions, annotations, elaborations, or other modifications
 | 
			
		||||
      represent, as a whole, an original work of authorship. For the purposes
 | 
			
		||||
      of this License, Derivative Works shall not include works that remain
 | 
			
		||||
      separable from, or merely link (or bind by name) to the interfaces of,
 | 
			
		||||
      the Work and Derivative Works thereof.
 | 
			
		||||
 | 
			
		||||
      "Contribution" shall mean any work of authorship, including
 | 
			
		||||
      the original version of the Work and any modifications or additions
 | 
			
		||||
      to that Work or Derivative Works thereof, that is intentionally
 | 
			
		||||
      submitted to Licensor for inclusion in the Work by the copyright owner
 | 
			
		||||
      or by an individual or Legal Entity authorized to submit on behalf of
 | 
			
		||||
      the copyright owner. For the purposes of this definition, "submitted"
 | 
			
		||||
      means any form of electronic, verbal, or written communication sent
 | 
			
		||||
      to the Licensor or its representatives, including but not limited to
 | 
			
		||||
      communication on electronic mailing lists, source code control systems,
 | 
			
		||||
      and issue tracking systems that are managed by, or on behalf of, the
 | 
			
		||||
      Licensor for the purpose of discussing and improving the Work, but
 | 
			
		||||
      excluding communication that is conspicuously marked or otherwise
 | 
			
		||||
      designated in writing by the copyright owner as "Not a Contribution."
 | 
			
		||||
 | 
			
		||||
      "Contributor" shall mean Licensor and any individual or Legal Entity
 | 
			
		||||
      on behalf of whom a Contribution has been received by Licensor and
 | 
			
		||||
      subsequently incorporated within the Work.
 | 
			
		||||
 | 
			
		||||
   2. Grant of Copyright License. Subject to the terms and conditions of
 | 
			
		||||
      this License, each Contributor hereby grants to You a perpetual,
 | 
			
		||||
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 | 
			
		||||
      copyright license to reproduce, prepare Derivative Works of,
 | 
			
		||||
      publicly display, publicly perform, sublicense, and distribute the
 | 
			
		||||
      Work and such Derivative Works in Source or Object form.
 | 
			
		||||
 | 
			
		||||
   3. Grant of Patent License. Subject to the terms and conditions of
 | 
			
		||||
      this License, each Contributor hereby grants to You a perpetual,
 | 
			
		||||
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 | 
			
		||||
      (except as stated in this section) patent license to make, have made,
 | 
			
		||||
      use, offer to sell, sell, import, and otherwise transfer the Work,
 | 
			
		||||
      where such license applies only to those patent claims licensable
 | 
			
		||||
      by such Contributor that are necessarily infringed by their
 | 
			
		||||
      Contribution(s) alone or by combination of their Contribution(s)
 | 
			
		||||
      with the Work to which such Contribution(s) was submitted. If You
 | 
			
		||||
      institute patent litigation against any entity (including a
 | 
			
		||||
      cross-claim or counterclaim in a lawsuit) alleging that the Work
 | 
			
		||||
      or a Contribution incorporated within the Work constitutes direct
 | 
			
		||||
      or contributory patent infringement, then any patent licenses
 | 
			
		||||
      granted to You under this License for that Work shall terminate
 | 
			
		||||
      as of the date such litigation is filed.
 | 
			
		||||
 | 
			
		||||
   4. Redistribution. You may reproduce and distribute copies of the
 | 
			
		||||
      Work or Derivative Works thereof in any medium, with or without
 | 
			
		||||
      modifications, and in Source or Object form, provided that You
 | 
			
		||||
      meet the following conditions:
 | 
			
		||||
 | 
			
		||||
      (a) You must give any other recipients of the Work or
 | 
			
		||||
          Derivative Works a copy of this License; and
 | 
			
		||||
 | 
			
		||||
      (b) You must cause any modified files to carry prominent notices
 | 
			
		||||
          stating that You changed the files; and
 | 
			
		||||
 | 
			
		||||
      (c) You must retain, in the Source form of any Derivative Works
 | 
			
		||||
          that You distribute, all copyright, patent, trademark, and
 | 
			
		||||
          attribution notices from the Source form of the Work,
 | 
			
		||||
          excluding those notices that do not pertain to any part of
 | 
			
		||||
          the Derivative Works; and
 | 
			
		||||
 | 
			
		||||
      (d) If the Work includes a "NOTICE" text file as part of its
 | 
			
		||||
          distribution, then any Derivative Works that You distribute must
 | 
			
		||||
          include a readable copy of the attribution notices contained
 | 
			
		||||
          within such NOTICE file, excluding those notices that do not
 | 
			
		||||
          pertain to any part of the Derivative Works, in at least one
 | 
			
		||||
          of the following places: within a NOTICE text file distributed
 | 
			
		||||
          as part of the Derivative Works; within the Source form or
 | 
			
		||||
          documentation, if provided along with the Derivative Works; or,
 | 
			
		||||
          within a display generated by the Derivative Works, if and
 | 
			
		||||
          wherever such third-party notices normally appear. The contents
 | 
			
		||||
          of the NOTICE file are for informational purposes only and
 | 
			
		||||
          do not modify the License. You may add Your own attribution
 | 
			
		||||
          notices within Derivative Works that You distribute, alongside
 | 
			
		||||
          or as an addendum to the NOTICE text from the Work, provided
 | 
			
		||||
          that such additional attribution notices cannot be construed
 | 
			
		||||
          as modifying the License.
 | 
			
		||||
 | 
			
		||||
      You may add Your own copyright statement to Your modifications and
 | 
			
		||||
      may provide additional or different license terms and conditions
 | 
			
		||||
      for use, reproduction, or distribution of Your modifications, or
 | 
			
		||||
      for any such Derivative Works as a whole, provided Your use,
 | 
			
		||||
      reproduction, and distribution of the Work otherwise complies with
 | 
			
		||||
      the conditions stated in this License.
 | 
			
		||||
 | 
			
		||||
   5. Submission of Contributions. Unless You explicitly state otherwise,
 | 
			
		||||
      any Contribution intentionally submitted for inclusion in the Work
 | 
			
		||||
      by You to the Licensor shall be under the terms and conditions of
 | 
			
		||||
      this License, without any additional terms or conditions.
 | 
			
		||||
      Notwithstanding the above, nothing herein shall supersede or modify
 | 
			
		||||
      the terms of any separate license agreement you may have executed
 | 
			
		||||
      with Licensor regarding such Contributions.
 | 
			
		||||
 | 
			
		||||
   6. Trademarks. This License does not grant permission to use the trade
 | 
			
		||||
      names, trademarks, service marks, or product names of the Licensor,
 | 
			
		||||
      except as required for reasonable and customary use in describing the
 | 
			
		||||
      origin of the Work and reproducing the content of the NOTICE file.
 | 
			
		||||
 | 
			
		||||
   7. Disclaimer of Warranty. Unless required by applicable law or
 | 
			
		||||
      agreed to in writing, Licensor provides the Work (and each
 | 
			
		||||
      Contributor provides its Contributions) on an "AS IS" BASIS,
 | 
			
		||||
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
 | 
			
		||||
      implied, including, without limitation, any warranties or conditions
 | 
			
		||||
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
 | 
			
		||||
      PARTICULAR PURPOSE. You are solely responsible for determining the
 | 
			
		||||
      appropriateness of using or redistributing the Work and assume any
 | 
			
		||||
      risks associated with Your exercise of permissions under this License.
 | 
			
		||||
 | 
			
		||||
   8. Limitation of Liability. In no event and under no legal theory,
 | 
			
		||||
      whether in tort (including negligence), contract, or otherwise,
 | 
			
		||||
      unless required by applicable law (such as deliberate and grossly
 | 
			
		||||
      negligent acts) or agreed to in writing, shall any Contributor be
 | 
			
		||||
      liable to You for damages, including any direct, indirect, special,
 | 
			
		||||
      incidental, or consequential damages of any character arising as a
 | 
			
		||||
      result of this License or out of the use or inability to use the
 | 
			
		||||
      Work (including but not limited to damages for loss of goodwill,
 | 
			
		||||
      work stoppage, computer failure or malfunction, or any and all
 | 
			
		||||
      other commercial damages or losses), even if such Contributor
 | 
			
		||||
      has been advised of the possibility of such damages.
 | 
			
		||||
 | 
			
		||||
   9. Accepting Warranty or Additional Liability. While redistributing
 | 
			
		||||
      the Work or Derivative Works thereof, You may choose to offer,
 | 
			
		||||
      and charge a fee for, acceptance of support, warranty, indemnity,
 | 
			
		||||
      or other liability obligations and/or rights consistent with this
 | 
			
		||||
      License. However, in accepting such obligations, You may act only
 | 
			
		||||
      on Your own behalf and on Your sole responsibility, not on behalf
 | 
			
		||||
      of any other Contributor, and only if You agree to indemnify,
 | 
			
		||||
      defend, and hold each Contributor harmless for any liability
 | 
			
		||||
      incurred by, or claims asserted against, such Contributor by reason
 | 
			
		||||
      of your accepting any such warranty or additional liability.
 | 
			
		||||
 | 
			
		||||
   END OF TERMS AND CONDITIONS
 | 
			
		||||
 | 
			
		||||
   APPENDIX: How to apply the Apache License to your work.
 | 
			
		||||
 | 
			
		||||
      To apply the Apache License to your work, attach the following
 | 
			
		||||
      boilerplate notice, with the fields enclosed by brackets "[]"
 | 
			
		||||
      replaced with your own identifying information. (Don't include
 | 
			
		||||
      the brackets!)  The text should be enclosed in the appropriate
 | 
			
		||||
      comment syntax for the file format. We also recommend that a
 | 
			
		||||
      file or class name and description of purpose be included on the
 | 
			
		||||
      same "printed page" as the copyright notice for easier
 | 
			
		||||
      identification within third-party archives.
 | 
			
		||||
 | 
			
		||||
   Copyright [yyyy] [name of copyright owner]
 | 
			
		||||
 | 
			
		||||
   Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
   you may not use this file except in compliance with the License.
 | 
			
		||||
   You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
       http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
   Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
   distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
   See the License for the specific language governing permissions and
 | 
			
		||||
   limitations under the License.
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
# LiveKit Meet Client
 | 
			
		||||
 | 
			
		||||
This is a clone of the LiveKit Meet open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://livekit.io/cloud), and Next.js. Used as a simple web interface to the 01 with screenshare and camera functionality. Can be run using a fully local model, STT, and TTS.
 | 
			
		||||
 | 
			
		||||
## Usage
 | 
			
		||||
 | 
			
		||||
Run the following command in the software directory to open a Meet instance.
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
poetry run 01 --server livekit --meet
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Setup
 | 
			
		||||
 | 
			
		||||
Ensure that you're in the meet directory. Then run `pnpm install` to install all dependencies. You're now all set to get up and running!
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
import { randomString } from '@/lib/client-utils';
 | 
			
		||||
import { ConnectionDetails } from '@/lib/types';
 | 
			
		||||
import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
 | 
			
		||||
const API_KEY = process.env.LIVEKIT_API_KEY;
 | 
			
		||||
const API_SECRET = process.env.LIVEKIT_API_SECRET;
 | 
			
		||||
const LIVEKIT_URL = process.env.LIVEKIT_URL;
 | 
			
		||||
 | 
			
		||||
export async function GET(request: NextRequest) {
 | 
			
		||||
  try {
 | 
			
		||||
    // Parse query parameters
 | 
			
		||||
    const roomName = request.nextUrl.searchParams.get('roomName');
 | 
			
		||||
    const participantName = request.nextUrl.searchParams.get('participantName');
 | 
			
		||||
    const metadata = request.nextUrl.searchParams.get('metadata') ?? '';
 | 
			
		||||
    const region = request.nextUrl.searchParams.get('region');
 | 
			
		||||
    const livekitServerUrl = region ? getLiveKitURL(region) : LIVEKIT_URL;
 | 
			
		||||
    if (livekitServerUrl === undefined) {
 | 
			
		||||
      throw new Error('Invalid region');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (typeof roomName !== 'string') {
 | 
			
		||||
      return new NextResponse('Missing required query parameter: roomName', { status: 400 });
 | 
			
		||||
    }
 | 
			
		||||
    if (participantName === null) {
 | 
			
		||||
      return new NextResponse('Missing required query parameter: participantName', { status: 400 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Generate participant token
 | 
			
		||||
    const participantToken = await createParticipantToken(
 | 
			
		||||
      {
 | 
			
		||||
        identity: `${participantName}__${randomString(4)}`,
 | 
			
		||||
        name: participantName,
 | 
			
		||||
        metadata,
 | 
			
		||||
      },
 | 
			
		||||
      roomName,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Return connection details
 | 
			
		||||
    const data: ConnectionDetails = {
 | 
			
		||||
      serverUrl: livekitServerUrl,
 | 
			
		||||
      roomName: roomName,
 | 
			
		||||
      participantToken: participantToken,
 | 
			
		||||
      participantName: participantName,
 | 
			
		||||
    };
 | 
			
		||||
    return NextResponse.json(data);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (error instanceof Error) {
 | 
			
		||||
      return new NextResponse(error.message, { status: 500 });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
 | 
			
		||||
  const at = new AccessToken(API_KEY, API_SECRET, userInfo);
 | 
			
		||||
  at.ttl = '5m';
 | 
			
		||||
  const grant: VideoGrant = {
 | 
			
		||||
    room: roomName,
 | 
			
		||||
    roomJoin: true,
 | 
			
		||||
    canPublish: true,
 | 
			
		||||
    canPublishData: true,
 | 
			
		||||
    canSubscribe: true,
 | 
			
		||||
  };
 | 
			
		||||
  at.addGrant(grant);
 | 
			
		||||
  return at.toJwt();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the LiveKit server URL for the given region.
 | 
			
		||||
 */
 | 
			
		||||
function getLiveKitURL(region: string | null): string {
 | 
			
		||||
  let targetKey = 'LIVEKIT_URL';
 | 
			
		||||
  if (region) {
 | 
			
		||||
    targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
 | 
			
		||||
  }
 | 
			
		||||
  const url = process.env[targetKey];
 | 
			
		||||
  if (!url) {
 | 
			
		||||
    throw new Error(`${targetKey} is not defined`);
 | 
			
		||||
  }
 | 
			
		||||
  return url;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,70 @@
 | 
			
		|||
import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk';
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
 | 
			
		||||
export async function GET(req: NextRequest) {
 | 
			
		||||
  try {
 | 
			
		||||
    const roomName = req.nextUrl.searchParams.get('roomName');
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * CAUTION:
 | 
			
		||||
     * for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
 | 
			
		||||
     * to start/stop recordings for that room.
 | 
			
		||||
     * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
 | 
			
		||||
     */
 | 
			
		||||
 | 
			
		||||
    if (roomName === null) {
 | 
			
		||||
      return new NextResponse('Missing roomName parameter', { status: 403 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
      LIVEKIT_API_KEY,
 | 
			
		||||
      LIVEKIT_API_SECRET,
 | 
			
		||||
      LIVEKIT_URL,
 | 
			
		||||
      S3_KEY_ID,
 | 
			
		||||
      S3_KEY_SECRET,
 | 
			
		||||
      S3_BUCKET,
 | 
			
		||||
      S3_ENDPOINT,
 | 
			
		||||
      S3_REGION,
 | 
			
		||||
    } = process.env;
 | 
			
		||||
 | 
			
		||||
    const hostURL = new URL(LIVEKIT_URL!);
 | 
			
		||||
    hostURL.protocol = 'https:';
 | 
			
		||||
 | 
			
		||||
    const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
 | 
			
		||||
 | 
			
		||||
    const existingEgresses = await egressClient.listEgress({ roomName });
 | 
			
		||||
    if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) {
 | 
			
		||||
      return new NextResponse('Meeting is already being recorded', { status: 409 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const fileOutput = new EncodedFileOutput({
 | 
			
		||||
      filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`,
 | 
			
		||||
      output: {
 | 
			
		||||
        case: 's3',
 | 
			
		||||
        value: new S3Upload({
 | 
			
		||||
          endpoint: S3_ENDPOINT,
 | 
			
		||||
          accessKey: S3_KEY_ID,
 | 
			
		||||
          secret: S3_KEY_SECRET,
 | 
			
		||||
          region: S3_REGION,
 | 
			
		||||
          bucket: S3_BUCKET,
 | 
			
		||||
        }),
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await egressClient.startRoomCompositeEgress(
 | 
			
		||||
      roomName,
 | 
			
		||||
      {
 | 
			
		||||
        file: fileOutput,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        layout: 'speaker',
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return new NextResponse(null, { status: 200 });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (error instanceof Error) {
 | 
			
		||||
      return new NextResponse(error.message, { status: 500 });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
import { EgressClient } from 'livekit-server-sdk';
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
 | 
			
		||||
export async function GET(req: NextRequest) {
 | 
			
		||||
  try {
 | 
			
		||||
    const roomName = req.nextUrl.searchParams.get('roomName');
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * CAUTION:
 | 
			
		||||
     * for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName
 | 
			
		||||
     * to start/stop recordings for that room.
 | 
			
		||||
     * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS
 | 
			
		||||
     */
 | 
			
		||||
 | 
			
		||||
    if (roomName === null) {
 | 
			
		||||
      return new NextResponse('Missing roomName parameter', { status: 403 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env;
 | 
			
		||||
 | 
			
		||||
    const hostURL = new URL(LIVEKIT_URL!);
 | 
			
		||||
    hostURL.protocol = 'https:';
 | 
			
		||||
 | 
			
		||||
    const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
 | 
			
		||||
    const activeEgresses = (await egressClient.listEgress({ roomName })).filter(
 | 
			
		||||
      (info) => info.status < 2,
 | 
			
		||||
    );
 | 
			
		||||
    if (activeEgresses.length === 0) {
 | 
			
		||||
      return new NextResponse('No active recording found', { status: 404 });
 | 
			
		||||
    }
 | 
			
		||||
    await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId)));
 | 
			
		||||
 | 
			
		||||
    return new NextResponse(null, { status: 200 });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (error instanceof Error) {
 | 
			
		||||
      return new NextResponse(error.message, { status: 500 });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,210 @@
 | 
			
		|||
import type {
 | 
			
		||||
    MessageDecoder,
 | 
			
		||||
    MessageEncoder,
 | 
			
		||||
    TrackReferenceOrPlaceholder,
 | 
			
		||||
    WidgetState,
 | 
			
		||||
  } from '@livekit/components-react';
 | 
			
		||||
  import { isTrackReference } from '@livekit/components-react';
 | 
			
		||||
  import { log } from './logger';
 | 
			
		||||
  import { isWeb } from './detectMobileBrowser';
 | 
			
		||||
  import { isEqualTrackRef } from './track-reference';
 | 
			
		||||
  import { RoomEvent, Track } from 'livekit-client';
 | 
			
		||||
  import * as React from 'react';
 | 
			
		||||
  import type { MessageFormatter } from '@livekit/components-react';
 | 
			
		||||
  import {
 | 
			
		||||
    CarouselLayout,
 | 
			
		||||
    ConnectionStateToast,
 | 
			
		||||
    FocusLayout,
 | 
			
		||||
    FocusLayoutContainer,
 | 
			
		||||
    GridLayout,
 | 
			
		||||
    LayoutContextProvider,
 | 
			
		||||
    ParticipantTile,
 | 
			
		||||
    RoomAudioRenderer,
 | 
			
		||||
  } from '@livekit/components-react';
 | 
			
		||||
  import { useCreateLayoutContext } from '@livekit/components-react';
 | 
			
		||||
  import { usePinnedTracks, useTracks } from '@livekit/components-react';
 | 
			
		||||
  import { ControlBar } from '@livekit/components-react';
 | 
			
		||||
  import { useWarnAboutMissingStyles } from './useWarnAboutMissingStyles';
 | 
			
		||||
  import { useLocalParticipant } from '@livekit/components-react';
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
 | 
			
		||||
    chatMessageFormatter?: MessageFormatter;
 | 
			
		||||
    chatMessageEncoder?: MessageEncoder;
 | 
			
		||||
    chatMessageDecoder?: MessageDecoder;
 | 
			
		||||
    /** @alpha */
 | 
			
		||||
    SettingsComponent?: React.ComponentType;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
 | 
			
		||||
   * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
 | 
			
		||||
   * of participants, basic non-persistent chat, screen sharing, and more.
 | 
			
		||||
   *
 | 
			
		||||
   * @remarks
 | 
			
		||||
   * The component is implemented with other LiveKit components like `FocusContextProvider`,
 | 
			
		||||
   * `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
 | 
			
		||||
   * You can use these components as a starting point for your own custom video conferencing application.
 | 
			
		||||
   *
 | 
			
		||||
   * @example
 | 
			
		||||
   * ```tsx
 | 
			
		||||
   * <LiveKitRoom>
 | 
			
		||||
   *   <VideoConference />
 | 
			
		||||
   * <LiveKitRoom>
 | 
			
		||||
   * ```
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  export function VideoConference({
 | 
			
		||||
    chatMessageFormatter,
 | 
			
		||||
    chatMessageDecoder,
 | 
			
		||||
    chatMessageEncoder,
 | 
			
		||||
    SettingsComponent,
 | 
			
		||||
    ...props
 | 
			
		||||
  }: VideoConferenceProps) {
 | 
			
		||||
    const [widgetState, setWidgetState] = React.useState<WidgetState>({
 | 
			
		||||
      showChat: false,
 | 
			
		||||
      unreadMessages: 0,
 | 
			
		||||
      showSettings: false,
 | 
			
		||||
    });
 | 
			
		||||
    const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null);
 | 
			
		||||
  
 | 
			
		||||
    const tracks = useTracks(
 | 
			
		||||
      [
 | 
			
		||||
        { source: Track.Source.Camera, withPlaceholder: true },
 | 
			
		||||
        { source: Track.Source.ScreenShare, withPlaceholder: false },
 | 
			
		||||
      ],
 | 
			
		||||
      { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
 | 
			
		||||
    );
 | 
			
		||||
  
 | 
			
		||||
    const widgetUpdate = (state: WidgetState) => {
 | 
			
		||||
      log.debug('updating widget state', state);
 | 
			
		||||
      setWidgetState(state);
 | 
			
		||||
    };
 | 
			
		||||
  
 | 
			
		||||
    const layoutContext = useCreateLayoutContext();
 | 
			
		||||
  
 | 
			
		||||
    const screenShareTracks = tracks
 | 
			
		||||
      .filter(isTrackReference)
 | 
			
		||||
      .filter((track) => track.publication.source === Track.Source.ScreenShare);
 | 
			
		||||
  
 | 
			
		||||
    const focusTrack = usePinnedTracks(layoutContext)?.[0];
 | 
			
		||||
    const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
 | 
			
		||||
 | 
			
		||||
    const { localParticipant } = useLocalParticipant();
 | 
			
		||||
 | 
			
		||||
    const [isAlwaysListening, setIsAlwaysListening] = React.useState(false);
 | 
			
		||||
 | 
			
		||||
    const toggleAlwaysListening = () => {
 | 
			
		||||
      const newValue = !isAlwaysListening;
 | 
			
		||||
      setIsAlwaysListening(newValue);
 | 
			
		||||
      handleAlwaysListeningToggle(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleAlwaysListeningToggle = (newValue: boolean) => {
 | 
			
		||||
  
 | 
			
		||||
        if (newValue) {
 | 
			
		||||
            console.log("SETTING VIDEO CONTEXT ON")
 | 
			
		||||
            const data = new TextEncoder().encode("{VIDEO_CONTEXT_ON}")
 | 
			
		||||
            localParticipant.publishData(data, {reliable: true, topic: "video_context"})
 | 
			
		||||
        } else {
 | 
			
		||||
            console.log("SETTING VIDEO CONTEXT OFF")
 | 
			
		||||
            const data = new TextEncoder().encode("{VIDEO_CONTEXT_OFF}")
 | 
			
		||||
            localParticipant.publishData(data, {reliable: true, topic: "video_context"})
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    React.useEffect(() => {
 | 
			
		||||
      // If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
 | 
			
		||||
      if (
 | 
			
		||||
        screenShareTracks.some((track) => track.publication.isSubscribed) &&
 | 
			
		||||
        lastAutoFocusedScreenShareTrack.current === null
 | 
			
		||||
      ) {
 | 
			
		||||
        log.debug('Auto set screen share focus:', { newScreenShareTrack: screenShareTracks[0] });
 | 
			
		||||
        layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
 | 
			
		||||
        lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
 | 
			
		||||
      } else if (
 | 
			
		||||
        lastAutoFocusedScreenShareTrack.current &&
 | 
			
		||||
        !screenShareTracks.some(
 | 
			
		||||
          (track) =>
 | 
			
		||||
            track.publication.trackSid ===
 | 
			
		||||
            lastAutoFocusedScreenShareTrack.current?.publication?.trackSid,
 | 
			
		||||
        )
 | 
			
		||||
      ) {
 | 
			
		||||
        log.debug('Auto clearing screen share focus.');
 | 
			
		||||
        layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
 | 
			
		||||
        lastAutoFocusedScreenShareTrack.current = null;
 | 
			
		||||
      }
 | 
			
		||||
      if (focusTrack && !isTrackReference(focusTrack)) {
 | 
			
		||||
        const updatedFocusTrack = tracks.find(
 | 
			
		||||
          (tr) =>
 | 
			
		||||
            tr.participant.identity === focusTrack.participant.identity &&
 | 
			
		||||
            tr.source === focusTrack.source,
 | 
			
		||||
        );
 | 
			
		||||
        if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
 | 
			
		||||
          layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }, [
 | 
			
		||||
      screenShareTracks
 | 
			
		||||
        .map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`)
 | 
			
		||||
        .join(),
 | 
			
		||||
      focusTrack?.publication?.trackSid,
 | 
			
		||||
      tracks,
 | 
			
		||||
    ]);
 | 
			
		||||
  
 | 
			
		||||
    useWarnAboutMissingStyles();
 | 
			
		||||
  
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="lk-video-conference" {...props}>
 | 
			
		||||
        {isWeb() && (
 | 
			
		||||
          <LayoutContextProvider
 | 
			
		||||
            value={layoutContext}
 | 
			
		||||
            // onPinChange={handleFocusStateChange}
 | 
			
		||||
            onWidgetChange={widgetUpdate}
 | 
			
		||||
          >
 | 
			
		||||
            <div className="lk-video-conference-inner">
 | 
			
		||||
              {!focusTrack ? (
 | 
			
		||||
                <div className="lk-grid-layout-wrapper">
 | 
			
		||||
                  <GridLayout tracks={tracks}>
 | 
			
		||||
                    <ParticipantTile />
 | 
			
		||||
                  </GridLayout>
 | 
			
		||||
                </div>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <div className="lk-focus-layout-wrapper">
 | 
			
		||||
                  <FocusLayoutContainer>
 | 
			
		||||
                    <CarouselLayout tracks={carouselTracks}>
 | 
			
		||||
                      <ParticipantTile />
 | 
			
		||||
                    </CarouselLayout>
 | 
			
		||||
                    {focusTrack && <FocusLayout trackRef={focusTrack} />}
 | 
			
		||||
                  </FocusLayoutContainer>
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
              <ControlBar
 | 
			
		||||
                controls={{ settings: !!SettingsComponent }}
 | 
			
		||||
              />
 | 
			
		||||
            <button
 | 
			
		||||
                className={`lk-button ${isAlwaysListening ? 'lk-button-active' : ''}`}
 | 
			
		||||
                onClick={toggleAlwaysListening}
 | 
			
		||||
            >
 | 
			
		||||
                {isAlwaysListening ? 'Stop Listening' : 'Start Listening'}
 | 
			
		||||
            </button>
 | 
			
		||||
              
 | 
			
		||||
            </div>
 | 
			
		||||
            {SettingsComponent && (
 | 
			
		||||
              <div
 | 
			
		||||
                className="lk-settings-menu-modal"
 | 
			
		||||
                style={{ display: widgetState.showSettings ? 'block' : 'none' }}
 | 
			
		||||
              >
 | 
			
		||||
                <SettingsComponent />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </LayoutContextProvider>
 | 
			
		||||
        )}
 | 
			
		||||
        <RoomAudioRenderer />
 | 
			
		||||
        <ConnectionStateToast />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
/**
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function isWeb(): boolean {
 | 
			
		||||
    return typeof document !== 'undefined';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Mobile browser detection based on `navigator.userAgent` string.
 | 
			
		||||
   * Defaults to returning `false` if not in a browser.
 | 
			
		||||
   *
 | 
			
		||||
   * @remarks
 | 
			
		||||
   * This should only be used if feature detection or other methods do not work!
 | 
			
		||||
   *
 | 
			
		||||
   * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_device_detection
 | 
			
		||||
   */
 | 
			
		||||
  export function isMobileBrowser(): boolean {
 | 
			
		||||
    return isWeb() ? /Mobi/i.test(window.navigator.userAgent) : false;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
import {
 | 
			
		||||
    setLogLevel as setClientSdkLogLevel,
 | 
			
		||||
    setLogExtension as setClientSdkLogExtension,
 | 
			
		||||
    LogLevel as LogLevelEnum,
 | 
			
		||||
  } from 'livekit-client';
 | 
			
		||||
  import loglevel from 'loglevel'
 | 
			
		||||
  
 | 
			
		||||
  export const log = loglevel.getLogger('lk-components-js');
 | 
			
		||||
  log.setDefaultLevel('WARN');
 | 
			
		||||
  
 | 
			
		||||
  type LogLevel = Parameters<typeof setClientSdkLogLevel>[0];
 | 
			
		||||
  type SetLogLevelOptions = {
 | 
			
		||||
    liveKitClientLogLevel?: LogLevel;
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Set the log level for both the `@livekit/components-react` package and the `@livekit-client` package.
 | 
			
		||||
   * To set the `@livekit-client` log independently, use the `liveKitClientLogLevel` prop on the `options` object.
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  export function setLogLevel(level: LogLevel, options: SetLogLevelOptions = {}): void {
 | 
			
		||||
    log.setLevel(level);
 | 
			
		||||
    setClientSdkLogLevel(options.liveKitClientLogLevel ?? level);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  type LogExtension = (level: LogLevel, msg: string, context?: object) => void;
 | 
			
		||||
  type SetLogExtensionOptions = {
 | 
			
		||||
    liveKitClientLogExtension?: LogExtension;
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Set the log extension for both the `@livekit/components-react` package and the `@livekit-client` package.
 | 
			
		||||
   * To set the `@livekit-client` log extension, use the `liveKitClientLogExtension` prop on the `options` object.
 | 
			
		||||
   * @public
 | 
			
		||||
   */
 | 
			
		||||
  export function setLogExtension(extension: LogExtension, options: SetLogExtensionOptions = {}) {
 | 
			
		||||
    const originalFactory = log.methodFactory;
 | 
			
		||||
  
 | 
			
		||||
    log.methodFactory = (methodName, configLevel, loggerName) => {
 | 
			
		||||
      const rawMethod = originalFactory(methodName, configLevel, loggerName);
 | 
			
		||||
  
 | 
			
		||||
      const logLevel = LogLevelEnum[methodName];
 | 
			
		||||
      const needLog = logLevel >= configLevel && logLevel < LogLevelEnum.silent;
 | 
			
		||||
  
 | 
			
		||||
      return (msg, context?: [msg: string, context: object]) => {
 | 
			
		||||
        if (context) rawMethod(msg, context);
 | 
			
		||||
        else rawMethod(msg);
 | 
			
		||||
        if (needLog) {
 | 
			
		||||
          extension(logLevel, msg, context);
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
    log.setLevel(log.getLevel()); // Be sure to call setLevel method in order to apply plugin
 | 
			
		||||
    setClientSdkLogExtension(options.liveKitClientLogExtension ?? extension);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright 2020 Adobe. All rights reserved.
 | 
			
		||||
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 * you may not use this file except in compliance with the License. You may obtain a copy
 | 
			
		||||
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 * Unless required by applicable law or agreed to in writing, software distributed under
 | 
			
		||||
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 | 
			
		||||
 * OF ANY KIND, either express or implied. See the License for the specific language
 | 
			
		||||
 * governing permissions and limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Calls all functions in the order they were chained with the same arguments.
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function chain(...callbacks: any[]): (...args: any[]) => void {
 | 
			
		||||
  return (...args: any[]) => {
 | 
			
		||||
    for (const callback of callbacks) {
 | 
			
		||||
      if (typeof callback === 'function') {
 | 
			
		||||
        try {
 | 
			
		||||
          callback(...args);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          console.error(e);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
 | 
			
		||||
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? V : never;
 | 
			
		||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
 | 
			
		||||
  ? I
 | 
			
		||||
  : never;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Merges multiple props objects together. Event handlers are chained,
 | 
			
		||||
 * classNames are combined, and ids are deduplicated - different ids
 | 
			
		||||
 * will trigger a side-effect and re-render components hooked up with `useId`.
 | 
			
		||||
 * For all other props, the last prop object overrides all previous ones.
 | 
			
		||||
 * @param args - Multiple sets of props to merge together.
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function mergeProps<T extends Props[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
 | 
			
		||||
  // Start with a base clone of the first argument. This is a lot faster than starting
 | 
			
		||||
  // with an empty object and adding properties as we go.
 | 
			
		||||
  const result: Props = { ...args[0] };
 | 
			
		||||
  for (let i = 1; i < args.length; i++) {
 | 
			
		||||
    const props = args[i];
 | 
			
		||||
    for (const key in props) {
 | 
			
		||||
      const a = result[key];
 | 
			
		||||
      const b = props[key];
 | 
			
		||||
 | 
			
		||||
      // Chain events
 | 
			
		||||
      if (
 | 
			
		||||
        typeof a === 'function' &&
 | 
			
		||||
        typeof b === 'function' &&
 | 
			
		||||
        // This is a lot faster than a regex.
 | 
			
		||||
        key[0] === 'o' &&
 | 
			
		||||
        key[1] === 'n' &&
 | 
			
		||||
        key.charCodeAt(2) >= /* 'A' */ 65 &&
 | 
			
		||||
        key.charCodeAt(2) <= /* 'Z' */ 90
 | 
			
		||||
      ) {
 | 
			
		||||
        result[key] = chain(a, b);
 | 
			
		||||
 | 
			
		||||
        // Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
 | 
			
		||||
      } else if (
 | 
			
		||||
        (key === 'className' || key === 'UNSAFE_className') &&
 | 
			
		||||
        typeof a === 'string' &&
 | 
			
		||||
        typeof b === 'string'
 | 
			
		||||
      ) {
 | 
			
		||||
        result[key] = clsx(a, b);
 | 
			
		||||
      } else {
 | 
			
		||||
        result[key] = b !== undefined ? b : a;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return result as UnionToIntersection<TupleTypes<T>>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
/**
 | 
			
		||||
 * The TrackReference type is a logical grouping of participant publication and/or subscribed track.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import type { Participant, Track, TrackPublication } from 'livekit-client';
 | 
			
		||||
// ## TrackReference Types
 | 
			
		||||
 | 
			
		||||
/** @public */
 | 
			
		||||
export type TrackReferencePlaceholder = {
 | 
			
		||||
  participant: Participant;
 | 
			
		||||
  publication?: never;
 | 
			
		||||
  source: Track.Source;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** @public */
 | 
			
		||||
export type TrackReference = {
 | 
			
		||||
  participant: Participant;
 | 
			
		||||
  publication: TrackPublication;
 | 
			
		||||
  source: Track.Source;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** @public */
 | 
			
		||||
export type TrackReferenceOrPlaceholder = TrackReference | TrackReferencePlaceholder;
 | 
			
		||||
 | 
			
		||||
// ### TrackReference Type Predicates
 | 
			
		||||
/** @internal */
 | 
			
		||||
export function isTrackReference(trackReference: unknown): trackReference is TrackReference {
 | 
			
		||||
  if (typeof trackReference === 'undefined') {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    isTrackReferenceSubscribed(trackReference as TrackReference) ||
 | 
			
		||||
    isTrackReferencePublished(trackReference as TrackReference)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isTrackReferenceSubscribed(trackReference?: TrackReferenceOrPlaceholder): boolean {
 | 
			
		||||
  if (!trackReference) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    trackReference.hasOwnProperty('participant') &&
 | 
			
		||||
    trackReference.hasOwnProperty('source') &&
 | 
			
		||||
    trackReference.hasOwnProperty('track') &&
 | 
			
		||||
    typeof trackReference.publication?.track !== 'undefined'
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isTrackReferencePublished(trackReference?: TrackReferenceOrPlaceholder): boolean {
 | 
			
		||||
  if (!trackReference) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    trackReference.hasOwnProperty('participant') &&
 | 
			
		||||
    trackReference.hasOwnProperty('source') &&
 | 
			
		||||
    trackReference.hasOwnProperty('publication') &&
 | 
			
		||||
    typeof trackReference.publication !== 'undefined'
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isTrackReferencePlaceholder(
 | 
			
		||||
  trackReference?: TrackReferenceOrPlaceholder,
 | 
			
		||||
): trackReference is TrackReferencePlaceholder {
 | 
			
		||||
  if (!trackReference) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    trackReference.hasOwnProperty('participant') &&
 | 
			
		||||
    trackReference.hasOwnProperty('source') &&
 | 
			
		||||
    typeof trackReference.publication === 'undefined'
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,97 @@
 | 
			
		|||
import type { Track } from 'livekit-client';
 | 
			
		||||
import type { PinState } from './types';
 | 
			
		||||
import type { TrackReferenceOrPlaceholder } from './track-reference-types';
 | 
			
		||||
import { isTrackReference, isTrackReferencePlaceholder } from './track-reference-types';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Returns a id to identify the `TrackReference` or `TrackReferencePlaceholder` based on
 | 
			
		||||
 * participant, track source and trackSid.
 | 
			
		||||
 * @remarks
 | 
			
		||||
 * The id pattern is: `${participantIdentity}_${trackSource}_${trackSid}` for `TrackReference`
 | 
			
		||||
 * and `${participantIdentity}_${trackSource}_placeholder` for `TrackReferencePlaceholder`.
 | 
			
		||||
 */
 | 
			
		||||
export function getTrackReferenceId(trackReference: TrackReferenceOrPlaceholder | number) {
 | 
			
		||||
  if (typeof trackReference === 'string' || typeof trackReference === 'number') {
 | 
			
		||||
    return `${trackReference}`;
 | 
			
		||||
  } else if (isTrackReferencePlaceholder(trackReference)) {
 | 
			
		||||
    return `${trackReference.participant.identity}_${trackReference.source}_placeholder`;
 | 
			
		||||
  } else if (isTrackReference(trackReference)) {
 | 
			
		||||
    return `${trackReference.participant.identity}_${trackReference.publication.source}_${trackReference.publication.trackSid}`;
 | 
			
		||||
  } else {
 | 
			
		||||
    throw new Error(`Can't generate a id for the given track reference: ${trackReference}`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type TrackReferenceId = ReturnType<typeof getTrackReferenceId>;
 | 
			
		||||
 | 
			
		||||
/** Returns the Source of the TrackReference. */
 | 
			
		||||
export function getTrackReferenceSource(trackReference: TrackReferenceOrPlaceholder): Track.Source {
 | 
			
		||||
  if (isTrackReference(trackReference)) {
 | 
			
		||||
    return trackReference.publication.source;
 | 
			
		||||
  } else {
 | 
			
		||||
    return trackReference.source;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isEqualTrackRef(
 | 
			
		||||
  a?: TrackReferenceOrPlaceholder,
 | 
			
		||||
  b?: TrackReferenceOrPlaceholder,
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (a === undefined || b === undefined) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  if (isTrackReference(a) && isTrackReference(b)) {
 | 
			
		||||
    return a.publication.trackSid === b.publication.trackSid;
 | 
			
		||||
  } else {
 | 
			
		||||
    return getTrackReferenceId(a) === getTrackReferenceId(b);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if the `TrackReference` is pinned.
 | 
			
		||||
 */
 | 
			
		||||
export function isTrackReferencePinned(
 | 
			
		||||
  trackReference: TrackReferenceOrPlaceholder,
 | 
			
		||||
  pinState: PinState | undefined,
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (typeof pinState === 'undefined') {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  if (isTrackReference(trackReference)) {
 | 
			
		||||
    return pinState.some(
 | 
			
		||||
      (pinnedTrackReference) =>
 | 
			
		||||
        pinnedTrackReference.participant.identity === trackReference.participant.identity &&
 | 
			
		||||
        isTrackReference(pinnedTrackReference) &&
 | 
			
		||||
        pinnedTrackReference.publication.trackSid === trackReference.publication.trackSid,
 | 
			
		||||
    );
 | 
			
		||||
  } else if (isTrackReferencePlaceholder(trackReference)) {
 | 
			
		||||
    return pinState.some(
 | 
			
		||||
      (pinnedTrackReference) =>
 | 
			
		||||
        pinnedTrackReference.participant.identity === trackReference.participant.identity &&
 | 
			
		||||
        isTrackReferencePlaceholder(pinnedTrackReference) &&
 | 
			
		||||
        pinnedTrackReference.source === trackReference.source,
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if the current `currentTrackRef` is the placeholder for next `nextTrackRef`.
 | 
			
		||||
 * Based on the participant identity and the source.
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function isPlaceholderReplacement(
 | 
			
		||||
  currentTrackRef: TrackReferenceOrPlaceholder,
 | 
			
		||||
  nextTrackRef: TrackReferenceOrPlaceholder,
 | 
			
		||||
) {
 | 
			
		||||
  // if (typeof nextTrackRef === 'number' || typeof currentTrackRef === 'number') {
 | 
			
		||||
  //   return false;
 | 
			
		||||
  // }
 | 
			
		||||
  return (
 | 
			
		||||
    isTrackReferencePlaceholder(currentTrackRef) &&
 | 
			
		||||
    isTrackReference(nextTrackRef) &&
 | 
			
		||||
    nextTrackRef.participant.identity === currentTrackRef.participant.identity &&
 | 
			
		||||
    nextTrackRef.source === currentTrackRef.source
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,90 @@
 | 
			
		|||
import type { Participant, ParticipantKind, Track, TrackPublication } from 'livekit-client';
 | 
			
		||||
import type { TrackReference, TrackReferenceOrPlaceholder } from './track-reference-types';
 | 
			
		||||
 | 
			
		||||
// ## PinState Type
 | 
			
		||||
/** @public */
 | 
			
		||||
export type PinState = TrackReferenceOrPlaceholder[];
 | 
			
		||||
export const PIN_DEFAULT_STATE: PinState = [];
 | 
			
		||||
 | 
			
		||||
// ## WidgetState Types
 | 
			
		||||
/** @public */
 | 
			
		||||
export type WidgetState = {
 | 
			
		||||
  showChat: boolean;
 | 
			
		||||
  unreadMessages: number;
 | 
			
		||||
  showSettings?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export const WIDGET_DEFAULT_STATE: WidgetState = {
 | 
			
		||||
  showChat: false,
 | 
			
		||||
  unreadMessages: 0,
 | 
			
		||||
  showSettings: false,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// ## Track Source Types
 | 
			
		||||
export type TrackSourceWithOptions = { source: Track.Source; withPlaceholder: boolean };
 | 
			
		||||
 | 
			
		||||
export type SourcesArray = Track.Source[] | TrackSourceWithOptions[];
 | 
			
		||||
 | 
			
		||||
// ### Track Source Type Predicates
 | 
			
		||||
export function isSourceWitOptions(source: SourcesArray[number]): source is TrackSourceWithOptions {
 | 
			
		||||
  return typeof source === 'object';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isSourcesWithOptions(sources: SourcesArray): sources is TrackSourceWithOptions[] {
 | 
			
		||||
  return (
 | 
			
		||||
    Array.isArray(sources) &&
 | 
			
		||||
    (sources as TrackSourceWithOptions[]).filter(isSourceWitOptions).length > 0
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ## Loop Filter Types
 | 
			
		||||
export type TrackReferenceFilter = Parameters<TrackReferenceOrPlaceholder[]['filter']>['0'];
 | 
			
		||||
export type ParticipantFilter = Parameters<Participant[]['filter']>['0'];
 | 
			
		||||
 | 
			
		||||
// ## Other Types
 | 
			
		||||
/** @internal */
 | 
			
		||||
export interface ParticipantClickEvent {
 | 
			
		||||
  participant: Participant;
 | 
			
		||||
  track?: TrackPublication;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type TrackSource<T extends Track.Source> = RequireAtLeastOne<
 | 
			
		||||
  { source: T; name: string; participant: Participant },
 | 
			
		||||
  'name' | 'source'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type ParticipantTrackIdentifier = RequireAtLeastOne<
 | 
			
		||||
  { sources: Track.Source[]; name: string; kind: Track.Kind },
 | 
			
		||||
  'sources' | 'name' | 'kind'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @beta
 | 
			
		||||
 */
 | 
			
		||||
export type ParticipantIdentifier = RequireAtLeastOne<
 | 
			
		||||
  { kind: ParticipantKind; identity: string },
 | 
			
		||||
  'identity' | 'kind'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The TrackIdentifier type is used to select Tracks either based on
 | 
			
		||||
 * - Track.Source and/or name of the track, e.g. `{source: Track.Source.Camera}` or `{name: "my-track"}`
 | 
			
		||||
 * - TrackReference (participant and publication)
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export type TrackIdentifier<T extends Track.Source = Track.Source> =
 | 
			
		||||
  | TrackSource<T>
 | 
			
		||||
  | TrackReference;
 | 
			
		||||
 | 
			
		||||
// ## Util Types
 | 
			
		||||
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
 | 
			
		||||
  {
 | 
			
		||||
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
 | 
			
		||||
  }[Keys];
 | 
			
		||||
 | 
			
		||||
export type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
 | 
			
		||||
  {
 | 
			
		||||
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
 | 
			
		||||
  }[Keys];
 | 
			
		||||
 | 
			
		||||
export type AudioSource = Track.Source.Microphone | Track.Source.ScreenShareAudio;
 | 
			
		||||
export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import * as React from 'react';
 | 
			
		||||
import { warnAboutMissingStyles } from './utils';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function useWarnAboutMissingStyles() {
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    warnAboutMissingStyles();
 | 
			
		||||
  }, []);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
import * as React from 'react';
 | 
			
		||||
import { mergeProps as mergePropsReactAria } from './mergeProps'
 | 
			
		||||
import { log } from './logger';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
 | 
			
		||||
/** @internal */
 | 
			
		||||
export function isProp<U extends HTMLElement, T extends React.HTMLAttributes<U>>(
 | 
			
		||||
  prop: T | undefined,
 | 
			
		||||
): prop is T {
 | 
			
		||||
  return prop !== undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** @internal */
 | 
			
		||||
export function mergeProps<
 | 
			
		||||
  U extends HTMLElement,
 | 
			
		||||
  T extends Array<React.HTMLAttributes<U> | undefined>,
 | 
			
		||||
>(...props: T) {
 | 
			
		||||
  return mergePropsReactAria(...props.filter(isProp));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** @internal */
 | 
			
		||||
export function cloneSingleChild(
 | 
			
		||||
  children: React.ReactNode | React.ReactNode[],
 | 
			
		||||
  props?: Record<string, any>,
 | 
			
		||||
  key?: any,
 | 
			
		||||
) {
 | 
			
		||||
  return React.Children.map(children, (child) => {
 | 
			
		||||
    // Checking isValidElement is the safe way and avoids a typescript
 | 
			
		||||
    // error too.
 | 
			
		||||
    if (React.isValidElement(child) && React.Children.only(children)) {
 | 
			
		||||
      if (child.props.class) {
 | 
			
		||||
        // make sure we retain classnames of both passed props and child
 | 
			
		||||
        props ??= {};
 | 
			
		||||
        props.class = clsx(child.props.class, props.class);
 | 
			
		||||
        props.style = { ...child.props.style, ...props.style };
 | 
			
		||||
      }
 | 
			
		||||
      return React.cloneElement(child, { ...props, key });
 | 
			
		||||
    }
 | 
			
		||||
    return child;
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @internal
 | 
			
		||||
 */
 | 
			
		||||
export function warnAboutMissingStyles(el?: HTMLElement) {
 | 
			
		||||
  if (
 | 
			
		||||
    typeof window !== 'undefined' &&
 | 
			
		||||
    typeof process !== 'undefined' &&
 | 
			
		||||
    // eslint-disable-next-line turbo/no-undeclared-env-vars
 | 
			
		||||
    (process?.env?.NODE_ENV === 'dev' ||
 | 
			
		||||
      // eslint-disable-next-line turbo/no-undeclared-env-vars
 | 
			
		||||
      process?.env?.NODE_ENV === 'development')
 | 
			
		||||
  ) {
 | 
			
		||||
    const target = el ?? document.querySelector('.lk-room-container');
 | 
			
		||||
    if (target && !getComputedStyle(target).getPropertyValue('--lk-has-imported-styles')) {
 | 
			
		||||
      log.warn(
 | 
			
		||||
        "It looks like you're not using the `@livekit/components-styles package`. To render the UI with the default styling, please import it in your layout or page.",
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,79 @@
 | 
			
		|||
'use client';
 | 
			
		||||
 | 
			
		||||
import { formatChatMessageLinks, LiveKitRoom } from '@livekit/components-react';
 | 
			
		||||
import {
 | 
			
		||||
  ExternalE2EEKeyProvider,
 | 
			
		||||
  LogLevel,
 | 
			
		||||
  Room,
 | 
			
		||||
  RoomConnectOptions,
 | 
			
		||||
  RoomOptions,
 | 
			
		||||
  VideoPresets,
 | 
			
		||||
  type VideoCodec,
 | 
			
		||||
} from 'livekit-client';
 | 
			
		||||
import { DebugMode } from '@/lib/Debug';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { decodePassphrase } from '@/lib/client-utils';
 | 
			
		||||
import { SettingsMenu } from '@/lib/SettingsMenu';
 | 
			
		||||
import { VideoConference } from '../components/VideoConference'
 | 
			
		||||
 | 
			
		||||
export function VideoConferenceClientImpl(props: {
 | 
			
		||||
  liveKitUrl: string;
 | 
			
		||||
  token: string;
 | 
			
		||||
  codec: VideoCodec | undefined;
 | 
			
		||||
}) {
 | 
			
		||||
  const worker =
 | 
			
		||||
    typeof window !== 'undefined' &&
 | 
			
		||||
    new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
 | 
			
		||||
  const keyProvider = new ExternalE2EEKeyProvider();
 | 
			
		||||
 | 
			
		||||
  const e2eePassphrase =
 | 
			
		||||
    typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined;
 | 
			
		||||
  const e2eeEnabled = !!(e2eePassphrase && worker);
 | 
			
		||||
  const roomOptions = useMemo((): RoomOptions => {
 | 
			
		||||
    return {
 | 
			
		||||
      publishDefaults: {
 | 
			
		||||
        videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
 | 
			
		||||
        red: !e2eeEnabled,
 | 
			
		||||
        videoCodec: props.codec,
 | 
			
		||||
      },
 | 
			
		||||
      adaptiveStream: { pixelDensity: 'screen' },
 | 
			
		||||
      dynacast: true,
 | 
			
		||||
      e2ee: e2eeEnabled
 | 
			
		||||
        ? {
 | 
			
		||||
            keyProvider,
 | 
			
		||||
            worker,
 | 
			
		||||
          }
 | 
			
		||||
        : undefined,
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const room = useMemo(() => new Room(roomOptions), []);
 | 
			
		||||
  if (e2eeEnabled) {
 | 
			
		||||
    keyProvider.setKey(e2eePassphrase);
 | 
			
		||||
    room.setE2EEEnabled(true);
 | 
			
		||||
  }
 | 
			
		||||
  const connectOptions = useMemo((): RoomConnectOptions => {
 | 
			
		||||
    return {
 | 
			
		||||
      autoSubscribe: true,
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <LiveKitRoom
 | 
			
		||||
      room={room}
 | 
			
		||||
      token={props.token}
 | 
			
		||||
      connectOptions={connectOptions}
 | 
			
		||||
      serverUrl={props.liveKitUrl}
 | 
			
		||||
      audio={true}
 | 
			
		||||
      video={true}
 | 
			
		||||
    >
 | 
			
		||||
      <VideoConference
 | 
			
		||||
        chatMessageFormatter={formatChatMessageLinks}
 | 
			
		||||
        SettingsComponent={
 | 
			
		||||
          process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <DebugMode logLevel={LogLevel.debug} />
 | 
			
		||||
    </LiveKitRoom>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
import { videoCodecs } from 'livekit-client';
 | 
			
		||||
import { VideoConferenceClientImpl } from './VideoConferenceClientImpl';
 | 
			
		||||
import { isVideoCodec } from '@/lib/types';
 | 
			
		||||
 | 
			
		||||
export default function CustomRoomConnection(props: {
 | 
			
		||||
  searchParams: {
 | 
			
		||||
    liveKitUrl?: string;
 | 
			
		||||
    token?: string;
 | 
			
		||||
    codec?: string;
 | 
			
		||||
  };
 | 
			
		||||
}) {
 | 
			
		||||
  const { liveKitUrl, token, codec } = props.searchParams;
 | 
			
		||||
  if (typeof liveKitUrl !== 'string') {
 | 
			
		||||
    return <h2>Missing LiveKit URL</h2>;
 | 
			
		||||
  }
 | 
			
		||||
  if (typeof token !== 'string') {
 | 
			
		||||
    return <h2>Missing LiveKit token</h2>;
 | 
			
		||||
  }
 | 
			
		||||
  if (codec !== undefined && !isVideoCodec(codec)) {
 | 
			
		||||
    return <h2>Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].</h2>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <main data-lk-theme="default" style={{ height: '100%' }}>
 | 
			
		||||
      <VideoConferenceClientImpl liveKitUrl={liveKitUrl} token={token} codec={codec} />
 | 
			
		||||
    </main>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
import '../styles/globals.css';
 | 
			
		||||
import '@livekit/components-styles';
 | 
			
		||||
import '@livekit/components-styles/prefabs';
 | 
			
		||||
import type { Metadata, Viewport } from 'next';
 | 
			
		||||
 | 
			
		||||
export const metadata: Metadata = {
 | 
			
		||||
  title: {
 | 
			
		||||
    default: 'LiveKit Meet | Conference app build with LiveKit open source',
 | 
			
		||||
    template: '%s',
 | 
			
		||||
  },
 | 
			
		||||
  description:
 | 
			
		||||
    'LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications.',
 | 
			
		||||
  twitter: {
 | 
			
		||||
    creator: '@livekitted',
 | 
			
		||||
    site: '@livekitted',
 | 
			
		||||
    card: 'summary_large_image',
 | 
			
		||||
  },
 | 
			
		||||
  openGraph: {
 | 
			
		||||
    url: 'https://meet.livekit.io',
 | 
			
		||||
    images: [
 | 
			
		||||
      {
 | 
			
		||||
        url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png',
 | 
			
		||||
        width: 2000,
 | 
			
		||||
        height: 1000,
 | 
			
		||||
        type: 'image/png',
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    siteName: 'LiveKit Meet',
 | 
			
		||||
  },
 | 
			
		||||
  icons: {
 | 
			
		||||
    icon: {
 | 
			
		||||
      rel: 'icon',
 | 
			
		||||
      url: '/favicon.ico',
 | 
			
		||||
    },
 | 
			
		||||
    apple: [
 | 
			
		||||
      {
 | 
			
		||||
        rel: 'apple-touch-icon',
 | 
			
		||||
        url: '/images/livekit-apple-touch.png',
 | 
			
		||||
        sizes: '180x180',
 | 
			
		||||
      },
 | 
			
		||||
      { rel: 'mask-icon', url: '/images/livekit-safari-pinned-tab.svg', color: '#070707' },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const viewport: Viewport = {
 | 
			
		||||
  themeColor: '#070707',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <html lang="en">
 | 
			
		||||
      <body>{children}</body>
 | 
			
		||||
    </html>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,201 @@
 | 
			
		|||
'use client';
 | 
			
		||||
 | 
			
		||||
import { useRouter, useSearchParams } from 'next/navigation';
 | 
			
		||||
import React, { Suspense, useState } from 'react';
 | 
			
		||||
import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils';
 | 
			
		||||
import styles from '../styles/Home.module.css';
 | 
			
		||||
 | 
			
		||||
function Tabs(props: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const searchParams = useSearchParams();
 | 
			
		||||
  const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
 | 
			
		||||
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  function onTabSelected(index: number) {
 | 
			
		||||
    const tab = index === 1 ? 'custom' : 'demo';
 | 
			
		||||
    router.push(`/?tab=${tab}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let tabs = React.Children.map(props.children, (child, index) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className="lk-button"
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          if (onTabSelected) {
 | 
			
		||||
            onTabSelected(index);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        aria-pressed={tabIndex === index}
 | 
			
		||||
      >
 | 
			
		||||
        {/* @ts-ignore */}
 | 
			
		||||
        {child?.props.label}
 | 
			
		||||
      </button>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.tabContainer}>
 | 
			
		||||
      <div className={styles.tabSelect}>{tabs}</div>
 | 
			
		||||
      {/* @ts-ignore */}
 | 
			
		||||
      {props.children[tabIndex]}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DemoMeetingTab(props: { label: string }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const [e2ee, setE2ee] = useState(false);
 | 
			
		||||
  const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
 | 
			
		||||
  const startMeeting = () => {
 | 
			
		||||
    if (e2ee) {
 | 
			
		||||
      router.push(`/rooms/${generateRoomId()}#${encodePassphrase(sharedPassphrase)}`);
 | 
			
		||||
    } else {
 | 
			
		||||
      router.push(`/rooms/${generateRoomId()}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.tabContent}>
 | 
			
		||||
      <p style={{ margin: 0 }}>Try LiveKit Meet for free with our live demo project.</p>
 | 
			
		||||
      <button style={{ marginTop: '1rem' }} className="lk-button" onClick={startMeeting}>
 | 
			
		||||
        Start Meeting
 | 
			
		||||
      </button>
 | 
			
		||||
      <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
 | 
			
		||||
        <div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
 | 
			
		||||
          <input
 | 
			
		||||
            id="use-e2ee"
 | 
			
		||||
            type="checkbox"
 | 
			
		||||
            checked={e2ee}
 | 
			
		||||
            onChange={(ev) => setE2ee(ev.target.checked)}
 | 
			
		||||
          ></input>
 | 
			
		||||
          <label htmlFor="use-e2ee">Enable end-to-end encryption</label>
 | 
			
		||||
        </div>
 | 
			
		||||
        {e2ee && (
 | 
			
		||||
          <div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
 | 
			
		||||
            <label htmlFor="passphrase">Passphrase</label>
 | 
			
		||||
            <input
 | 
			
		||||
              id="passphrase"
 | 
			
		||||
              type="password"
 | 
			
		||||
              value={sharedPassphrase}
 | 
			
		||||
              onChange={(ev) => setSharedPassphrase(ev.target.value)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CustomConnectionTab(props: { label: string }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
 | 
			
		||||
  const [e2ee, setE2ee] = useState(false);
 | 
			
		||||
  const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
 | 
			
		||||
 | 
			
		||||
  const onSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    const formData = new FormData(event.target as HTMLFormElement);
 | 
			
		||||
    const serverUrl = formData.get('serverUrl');
 | 
			
		||||
    const token = formData.get('token');
 | 
			
		||||
    if (e2ee) {
 | 
			
		||||
      router.push(
 | 
			
		||||
        `/custom/?liveKitUrl=${serverUrl}&token=${token}#${encodePassphrase(sharedPassphrase)}`,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      router.push(`/custom/?liveKitUrl=${serverUrl}&token=${token}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <form className={styles.tabContent} onSubmit={onSubmit}>
 | 
			
		||||
      <p style={{ marginTop: 0 }}>
 | 
			
		||||
        Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server.
 | 
			
		||||
      </p>
 | 
			
		||||
      <input
 | 
			
		||||
        id="serverUrl"
 | 
			
		||||
        name="serverUrl"
 | 
			
		||||
        type="url"
 | 
			
		||||
        placeholder="LiveKit Server URL: wss://*.livekit.cloud"
 | 
			
		||||
        required
 | 
			
		||||
      />
 | 
			
		||||
      <textarea
 | 
			
		||||
        id="token"
 | 
			
		||||
        name="token"
 | 
			
		||||
        placeholder="Token"
 | 
			
		||||
        required
 | 
			
		||||
        rows={5}
 | 
			
		||||
        style={{ padding: '1px 2px', fontSize: 'inherit', lineHeight: 'inherit' }}
 | 
			
		||||
      />
 | 
			
		||||
      <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
 | 
			
		||||
        <div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
 | 
			
		||||
          <input
 | 
			
		||||
            id="use-e2ee"
 | 
			
		||||
            type="checkbox"
 | 
			
		||||
            checked={e2ee}
 | 
			
		||||
            onChange={(ev) => setE2ee(ev.target.checked)}
 | 
			
		||||
          ></input>
 | 
			
		||||
          <label htmlFor="use-e2ee">Enable end-to-end encryption</label>
 | 
			
		||||
        </div>
 | 
			
		||||
        {e2ee && (
 | 
			
		||||
          <div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
 | 
			
		||||
            <label htmlFor="passphrase">Passphrase</label>
 | 
			
		||||
            <input
 | 
			
		||||
              id="passphrase"
 | 
			
		||||
              type="password"
 | 
			
		||||
              value={sharedPassphrase}
 | 
			
		||||
              onChange={(ev) => setSharedPassphrase(ev.target.value)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <hr
 | 
			
		||||
        style={{ width: '100%', borderColor: 'rgba(255, 255, 255, 0.15)', marginBlock: '1rem' }}
 | 
			
		||||
      />
 | 
			
		||||
      <button
 | 
			
		||||
        style={{ paddingInline: '1.25rem', width: '100%' }}
 | 
			
		||||
        className="lk-button"
 | 
			
		||||
        type="submit"
 | 
			
		||||
      >
 | 
			
		||||
        Connect
 | 
			
		||||
      </button>
 | 
			
		||||
    </form>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Page() {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <main className={styles.main} data-lk-theme="default">
 | 
			
		||||
        <div className="header">
 | 
			
		||||
          <img src="/images/livekit-meet-home.svg" alt="LiveKit Meet" width="360" height="45" />
 | 
			
		||||
          <h2>
 | 
			
		||||
            Open source video conferencing app built on{' '}
 | 
			
		||||
            <a href="https://github.com/livekit/components-js?ref=meet" rel="noopener">
 | 
			
		||||
              LiveKit 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  | 
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" width="961" height="121" fill="none"><path fill="#fff" d="M20.2 0H0v118.1h73.4V101H20.2V0ZM106.9 53.8H87.2V118H107V53.8ZM164.6 115.8l-25-81.5H120l26.2 83.8H183l26.2-83.8h-19.8l-24.8 81.5ZM257.8 32.5c-25.4 0-41.6 18-41.6 43.7 0 25.5 15.7 43.8 41.6 43.8 19.8 0 34-8.7 39.4-26.6h-20c-3 8.1-8.4 13-19.2 13-12 0-20.3-8.3-21.9-24.5h62.5c.3-2 .5-4.1.5-6.2 0-26.1-16.3-43.2-41.3-43.2Zm-21.5 35.9c2-15 10-22.2 21.5-22.2 12.1 0 20.3 8.8 21.2 22.2h-42.7ZM413.8 0h-25.5l-49.2 54V0h-20.3v118.1h20.3V58.4l54.3 59.7h25.9L362.5 56l51.3-56ZM447.7 34.3H428v64.4h19.7V34.3ZM87.2 34.3H67.6v19.5h19.6V34.3ZM467.3 98.7h-19.6v19.4h19.6V98.7ZM525.9 98.7h-19.6v19.4h19.6V98.7ZM525.9 53.8V34.3h-19.6V0h-19.7v34.3H467v19.5h19.6v44.9h19.7v-45h19.6Z"/><path fill="#FF6352" d="M589.8 119V.4h-10.7V119h10.7Zm53.9 0L602.3.4H591L632.4 119h11.3Zm12.3 0L697.3.4h-11.2L644.7 119H656Zm53.2 0V.4h-10.6V119h10.6Zm99.4-42.9c0-25.6-16.4-41.8-38.4-41.8-23 0-38.7 17.5-38.7 43.2 0 25.9 15.6 43.2 39.2 43.2 18 0 31.3-8.4 36.2-26h-10.6c-3.6 11.4-11.7 18.2-25.6 18.2-16.4 0-27.8-11.8-28.7-32.7h66.3c.1-1.8.3-2.7.3-4.1Zm-38.4-34c16 0 26.8 12.8 27.8 30.1H742c1.7-18.9 12.4-30.1 28.1-30.1Zm130.4 34c0-25.6-16.4-41.8-38.4-41.8-23 0-38.7 17.5-38.7 43.2 0 25.9 15.6 43.2 39.2 43.2 18 0 31.3-8.4 36.2-26h-10.6c-3.6 11.4-11.7 18.2-25.6 18.2-16.4 0-27.8-11.8-28.7-32.7h66.3c.1-1.8.3-2.7.3-4.1Zm-38.4-34c16 0 26.9 12.8 27.8 30.1H834c1.8-18.9 12.4-30.1 28.1-30.1Zm88.3 69c-8.7 0-13.5-3.5-13.5-13.2V44h22.9v-8h-23V16.4h-10.2V36H908v8h18.6v53.9c0 14.4 9.3 21.1 22.9 21.1H960v-8h-9.5Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.5 KiB  | 
| 
		 After Width: | Height: | Size: 22 KiB  | 
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><path fill="#000" fill-rule="evenodd" d="M0 0h512v512H0zm288 224h-64v64h64v64H160V96H96v320h192v-64h64v64h64v-64h-64v-64h-64v-64h64v-64h64V96h-64v64h-64z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 259 B  | 
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
{
 | 
			
		||||
  "extends": ["config:base"],
 | 
			
		||||
  "packageRules": [
 | 
			
		||||
    {
 | 
			
		||||
      "schedule": "before 6am on the first day of the month",
 | 
			
		||||
      "matchDepTypes": ["devDependencies"],
 | 
			
		||||
      "matchUpdateTypes": ["patch", "minor"],
 | 
			
		||||
      "groupName": "devDependencies (non-major)"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchSourceUrlPrefixes": ["https://github.com/livekit/"],
 | 
			
		||||
      "rangeStrategy": "replace",
 | 
			
		||||
      "groupName": "LiveKit dependencies (non-major)",
 | 
			
		||||
      "automerge": true
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
.overlay {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  background: rgba(0, 0, 0, 0.6);
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  max-height: min(100%, 100vh);
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detailsSection {
 | 
			
		||||
  padding-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detailsSection > div {
 | 
			
		||||
  padding-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
.main {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: grid;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  place-content: center;
 | 
			
		||||
  justify-items: center;
 | 
			
		||||
  overflow: auto;
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabContainer {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  max-width: 500px;
 | 
			
		||||
  padding-inline: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabSelect {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: stretch;
 | 
			
		||||
  gap: 0.125rem;
 | 
			
		||||
  padding: 0.125rem;
 | 
			
		||||
  margin: 0 auto 1.5rem;
 | 
			
		||||
  border: 1px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabSelect > * {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabContent {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 0.75rem;
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
  border: 1px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
.settingsCloseButton {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  right: var(--lk-grid-gap);
 | 
			
		||||
  bottom: var(--lk-grid-gap);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabs {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabs > .tab {
 | 
			
		||||
  padding: 0.5rem;
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
  padding-bottom: 0.5rem;
 | 
			
		||||
  border-bottom: 3px solid;
 | 
			
		||||
  border-color: var(--bg5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tabs > .tab[aria-pressed='true'] {
 | 
			
		||||
  border-color: var(--lk-accent-bg);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
* {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
  color-scheme: dark;
 | 
			
		||||
  background-color: #111;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html,
 | 
			
		||||
body {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  margin: 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  max-width: 500px;
 | 
			
		||||
  padding-inline: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header > img {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: auto;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header > h2 {
 | 
			
		||||
  font-family: 'TWK Everett', sans-serif;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-size: 1.25rem;
 | 
			
		||||
  line-height: 144%;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: rgba(255, 255, 255, 0.6);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
footer {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 1.5rem 2rem;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: rgba(255, 255, 255, 0.6);
 | 
			
		||||
  background-color: var(--lk-bg);
 | 
			
		||||
  border-top: 1px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
footer a,
 | 
			
		||||
h2 a {
 | 
			
		||||
  color: #ff6352;
 | 
			
		||||
  text-decoration-color: #a33529;
 | 
			
		||||
  text-underline-offset: 0.125em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
footer a:hover,
 | 
			
		||||
h2 a {
 | 
			
		||||
  text-decoration-color: #ff6352;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h2 a {
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "target": "ES2020",
 | 
			
		||||
    "lib": ["dom", "dom.iterable", "ES2020"],
 | 
			
		||||
    "allowJs": true,
 | 
			
		||||
    "skipLibCheck": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "forceConsistentCasingInFileNames": true,
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    "esModuleInterop": true,
 | 
			
		||||
    "module": "ES2020",
 | 
			
		||||
    "moduleResolution": "Bundler",
 | 
			
		||||
    "resolveJsonModule": true,
 | 
			
		||||
    "isolatedModules": true,
 | 
			
		||||
    "jsx": "preserve",
 | 
			
		||||
    "incremental": true,
 | 
			
		||||
    "sourceMap": true,
 | 
			
		||||
    "plugins": [
 | 
			
		||||
      {
 | 
			
		||||
        "name": "next"
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/*": ["./*"]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
 | 
			
		||||
  "exclude": ["node_modules"]
 | 
			
		||||
}
 | 
			
		||||