├── .browserslistrc ├── .changeset ├── README.md └── config.json ├── .env.example ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_internal.yml ├── content │ ├── logo-dark.svg │ └── logo-light.svg ├── dependabot.yml ├── pull_request_template.md ├── stale.yml └── workflows │ ├── cache.yml │ ├── main.yml │ ├── release.yml │ ├── snapshot.yml │ └── tag.yml ├── .gitignore ├── .node-version ├── .vscode ├── extensions.json └── settings.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── SECURITY.md ├── apps ├── docs-embed │ ├── components-to-string.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── app │ │ │ ├── broadcast │ │ │ │ ├── [key] │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── loading.tsx │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── player │ │ │ │ ├── [key] │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── loading.tsx │ │ ├── components │ │ │ ├── broadcast │ │ │ │ ├── audio.tsx │ │ │ │ ├── camera.tsx │ │ │ │ ├── container.tsx │ │ │ │ ├── controls.tsx │ │ │ │ ├── enabled.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── fullscreen.tsx │ │ │ │ ├── getting-started.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── pip.tsx │ │ │ │ ├── portal.tsx │ │ │ │ ├── root.tsx │ │ │ │ ├── screenshare.tsx │ │ │ │ ├── source.tsx │ │ │ │ ├── status.tsx │ │ │ │ ├── stream-key.ts │ │ │ │ ├── use-broadcast-context.tsx │ │ │ │ └── video.tsx │ │ │ ├── code │ │ │ │ ├── code-server.tsx │ │ │ │ ├── code.tsx │ │ │ │ └── shiki.css │ │ │ └── player │ │ │ │ ├── clip.tsx │ │ │ │ ├── container.tsx │ │ │ │ ├── controls.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── fullscreen.tsx │ │ │ │ ├── getting-started.tsx │ │ │ │ ├── live.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── pip.tsx │ │ │ │ ├── play.tsx │ │ │ │ ├── portal.tsx │ │ │ │ ├── poster.tsx │ │ │ │ ├── rate.tsx │ │ │ │ ├── root.tsx │ │ │ │ ├── seek.tsx │ │ │ │ ├── source.ts │ │ │ │ ├── time.tsx │ │ │ │ ├── use-media-context.tsx │ │ │ │ ├── video-quality.tsx │ │ │ │ ├── video.tsx │ │ │ │ └── volume.tsx │ │ └── lib │ │ │ ├── broadcast-components.ts │ │ │ ├── code-components.ts │ │ │ ├── components.ts │ │ │ ├── livepeer.ts │ │ │ ├── player-components.ts │ │ │ ├── shiki.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json └── lvpr-tv │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── app │ │ ├── broadcast │ │ │ └── [key] │ │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components │ │ ├── IframeMessenger.tsx │ │ ├── PlayerErrorMonitor.tsx │ │ ├── broadcast │ │ │ ├── Broadcast.tsx │ │ │ └── Settings.tsx │ │ └── player │ │ │ ├── Clip.tsx │ │ │ ├── CurrentSource.tsx │ │ │ ├── ForceError.tsx │ │ │ ├── Player.tsx │ │ │ ├── Settings.tsx │ │ │ └── actions.ts │ └── lib │ │ ├── livepeer.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json ├── biome.json ├── examples ├── README.md ├── next-pages │ ├── .env.example │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── components │ │ │ ├── Clip.tsx │ │ │ ├── Player.tsx │ │ │ └── Settings.tsx │ │ ├── lib │ │ │ ├── livepeer.ts │ │ │ └── utils.ts │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── alternative-player.tsx │ │ │ ├── api │ │ │ │ ├── clip.ts │ │ │ │ └── jwt.ts │ │ │ ├── globals.css │ │ │ └── index.tsx │ │ └── public │ │ │ └── favicon.ico │ ├── tailwind.config.ts │ └── tsconfig.json ├── next │ ├── .env.example │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── app │ │ │ ├── broadcast │ │ │ │ ├── Broadcast.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ └── page.tsx │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── livepeer.ts │ │ │ ├── page.tsx │ │ │ └── player │ │ │ │ └── [type] │ │ │ │ ├── Clip.tsx │ │ │ │ ├── CurrentSource.tsx │ │ │ │ ├── ForceError.tsx │ │ │ │ ├── Player.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ └── lib │ │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json └── with-pubnub │ ├── .env.example │ ├── Readme.md │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ └── banned.png │ ├── src │ ├── app │ │ ├── actions.ts │ │ ├── create-livestream-button.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── view │ │ │ └── [playbackId] │ │ │ ├── PlayerWithChat.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── components │ │ ├── broadcast │ │ │ ├── Broadcast.tsx │ │ │ └── Settings.tsx │ │ ├── chat │ │ │ ├── Chat.tsx │ │ │ ├── api │ │ │ │ ├── README.md │ │ │ │ └── moderation.ts │ │ │ ├── components │ │ │ │ ├── admin.tsx │ │ │ │ ├── cards │ │ │ │ │ ├── flagged-message-card.tsx │ │ │ │ │ ├── flagged-user-card.tsx │ │ │ │ │ └── restricted-user-card.tsx │ │ │ │ ├── dropdown.tsx │ │ │ │ ├── message.tsx │ │ │ │ └── sign-in.tsx │ │ │ └── context │ │ │ │ └── ChatContext.tsx │ │ └── player │ │ │ ├── Clip.tsx │ │ │ ├── Player.tsx │ │ │ ├── Settings.tsx │ │ │ └── actions.ts │ └── lib │ │ ├── livepeer.ts │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json ├── generate-version.ts ├── package.json ├── packages ├── core-react │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── test │ │ ├── index.tsx │ │ └── setup.ts │ ├── tsconfig.json │ └── tsup.config.js ├── core-web │ ├── CHANGELOG.md │ ├── README.md │ ├── global-browser.d.ts │ ├── package.json │ ├── src │ │ ├── broadcast.test.ts │ │ ├── broadcast.ts │ │ ├── browser.test.ts │ │ ├── browser.ts │ │ ├── external.test.ts │ │ ├── external.ts │ │ ├── hls.test.ts │ │ ├── hls.ts │ │ ├── hls │ │ │ └── hls.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── media.test.ts │ │ ├── media.ts │ │ ├── media │ │ │ ├── controls │ │ │ │ ├── controller.ts │ │ │ │ ├── device.ts │ │ │ │ ├── fullscreen.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pictureInPicture.ts │ │ │ │ └── volume.ts │ │ │ ├── metrics.ts │ │ │ └── utils.ts │ │ ├── webrtc.test.ts │ │ ├── webrtc.ts │ │ └── webrtc │ │ │ ├── shared.ts │ │ │ ├── whep.ts │ │ │ └── whip.ts │ ├── test │ │ ├── index.ts │ │ ├── mocks.ts │ │ ├── sample.mp4 │ │ ├── setup.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsup.config.js ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── crypto │ │ │ ├── ecdsa.ts │ │ │ ├── getSubtleCrypto.ts │ │ │ ├── jwt.test.ts │ │ │ ├── jwt.ts │ │ │ └── pkcs8.ts │ │ ├── errors.test.ts │ │ ├── errors.ts │ │ ├── external.test.ts │ │ ├── external.ts │ │ ├── media.test.ts │ │ ├── media.ts │ │ ├── media │ │ │ ├── controller.ts │ │ │ ├── errors.ts │ │ │ ├── external.test.ts │ │ │ ├── external.ts │ │ │ ├── metrics-new.test.ts │ │ │ ├── metrics-new.ts │ │ │ ├── metrics-utils.test.ts │ │ │ ├── metrics-utils.ts │ │ │ ├── metrics.ts │ │ │ ├── mime.ts │ │ │ ├── src.ts │ │ │ ├── storage.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ ├── storage.test.ts │ │ ├── storage.ts │ │ ├── utils.test.ts │ │ ├── utils.ts │ │ ├── utils │ │ │ ├── deepMerge.test.ts │ │ │ ├── deepMerge.ts │ │ │ ├── omick.test.ts │ │ │ ├── omick.ts │ │ │ ├── storage │ │ │ │ ├── arweave.test.ts │ │ │ │ ├── arweave.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ipfs.test.ts │ │ │ │ └── ipfs.ts │ │ │ ├── string.test.ts │ │ │ ├── string.ts │ │ │ ├── types.ts │ │ │ ├── warn.test.ts │ │ │ └── warn.ts │ │ ├── version.test.ts │ │ └── version.ts │ ├── test │ │ ├── index.ts │ │ ├── mocks.ts │ │ ├── sample.mp4 │ │ ├── setup.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsup.config.js └── react │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── assets.test.ts │ ├── assets.tsx │ ├── broadcast.test.ts │ ├── broadcast.tsx │ ├── broadcast │ │ ├── AudioEnabled.tsx │ │ ├── Broadcast.tsx │ │ ├── Controls.tsx │ │ ├── Enabled.tsx │ │ ├── Screenshare.tsx │ │ ├── SourceSelect.tsx │ │ ├── StatusIndicator.tsx │ │ ├── Video.tsx │ │ ├── VideoEnabled.tsx │ │ └── context.tsx │ ├── crypto.test.ts │ ├── crypto.ts │ ├── external.test.ts │ ├── external.ts │ ├── index.test.ts │ ├── index.ts │ ├── player.test.ts │ ├── player.tsx │ ├── player │ │ ├── ClipTrigger.tsx │ │ ├── Controls.tsx │ │ ├── LiveIndicator.tsx │ │ ├── MuteTrigger.tsx │ │ ├── Play.tsx │ │ ├── Player.tsx │ │ ├── Poster.tsx │ │ ├── RateSelect.tsx │ │ ├── Seek.tsx │ │ ├── Video.tsx │ │ ├── VideoQualitySelect.tsx │ │ └── Volume.tsx │ └── shared │ │ ├── Container.tsx │ │ ├── ErrorIndicator.tsx │ │ ├── Fullscreen.tsx │ │ ├── LoadingIndicator.tsx │ │ ├── PictureInPictureTrigger.tsx │ │ ├── Portal.tsx │ │ ├── Select.tsx │ │ ├── Slider.tsx │ │ ├── Time.tsx │ │ ├── context.tsx │ │ ├── primitive.tsx │ │ └── utils.ts │ ├── test │ ├── index.tsx │ └── setup.ts │ ├── tsconfig.json │ └── tsup.config.js ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json ├── turbo.json └── vitest.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | 3 | [production] 4 | last 2 versions 5 | > 0.2% 6 | not dead 7 | 8 | [ssr] 9 | node 12 10 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.3/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "livepeer/ui-kit" }], 4 | "commit": false, 5 | "access": "public", 6 | "baseBranch": "main", 7 | "updateInternalDependencies": "patch", 8 | "ignore": ["example-*", "app-*"] 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | STUDIO_API_KEY= 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug/issue 3 | title: "[bug] " 4 | labels: ["bug", "triage", "team: studio"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! The more info you provide, the more we can help you. 10 | 11 | - type: checkboxes 12 | attributes: 13 | label: Is there an existing issue for this? 14 | description: Please search to see if an issue already exists for the bug you encountered. 15 | options: 16 | - label: I have searched the existing issues 17 | required: true 18 | 19 | - type: input 20 | attributes: 21 | label: Package Version 22 | description: What version of Livepeer UI Kit are you using? 23 | placeholder: 0.4.0 24 | validations: 25 | required: true 26 | 27 | - type: textarea 28 | attributes: 29 | label: Current Behavior 30 | description: A concise description of what you're experiencing. 31 | validations: 32 | required: false 33 | 34 | - type: textarea 35 | attributes: 36 | label: Expected Behavior 37 | description: A concise description of what you expected to happen. 38 | validations: 39 | required: false 40 | 41 | - type: textarea 42 | attributes: 43 | label: Steps To Reproduce 44 | description: Steps or code snippets to reproduce the behavior. 45 | validations: 46 | required: false 47 | 48 | - type: input 49 | attributes: 50 | label: Link to Minimal Reproducible Example (CodeSandbox, StackBlitz, etc.) 51 | description: | 52 | This makes investigating issues and helping you out significantly easier! For most issues, you will likely get asked to provide one so why not add one now :) 53 | placeholder: https://codesandbox.io 54 | validations: 55 | required: false 56 | 57 | - type: textarea 58 | attributes: 59 | label: Anything else? 60 | description: | 61 | Browser info? Screenshots? Anything that will give us more context about the issue you are encountering! 62 | 63 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 64 | validations: 65 | required: false 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask Question 4 | url: https://github.com/livepeer/ui-kit/discussions/new?category=q-a 5 | about: Ask questions and discuss with other community members 6 | - name: Request Feature 7 | url: https://github.com/livepeer/ui-kit/discussions/new?category=ideas 8 | about: Requests features or brainstorm ideas for new functionality 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_internal.yml: -------------------------------------------------------------------------------- 1 | name: Request Feature (Internal Team) 2 | description: File a feature (internal team use only) 3 | title: '[feature] <title>' 4 | labels: ['feature', 'triage', 'team: studio'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for adding a feature request! _Note: this is only for the internal Livepeer team - please use 10 | [Discussions](https://github.com/livepeer/ui-kit/discussions) if you are not a maintainer of this project._ 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A concise description of the feature request. 15 | validations: 16 | required: false 17 | -------------------------------------------------------------------------------- /.github/content/logo-dark.svg: -------------------------------------------------------------------------------- 1 | <svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | <circle cx="512" cy="512" r="512" fill="white" /> 4 | <path fill-rule="evenodd" clip-rule="evenodd" 5 | d="M292 662L292 762L392 762L392 662L292 662ZM292 462L292 562L392 562L392 462L292 462ZM692 462L692 562L792.001 562L792.001 462L692 462ZM292 362L292 262L392 262L392 362L292 362ZM492 461.999L492 361.999L592 361.999L592 461.999L492 461.999ZM492 561.999L492 661.999L592 661.999L592 561.999L492 561.999Z" 6 | fill="#131418" /> 7 | </svg> -------------------------------------------------------------------------------- /.github/content/logo-light.svg: -------------------------------------------------------------------------------- 1 | <svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | <circle cx="512" cy="512" r="512" fill="#131418" /> 4 | <path fill-rule="evenodd" clip-rule="evenodd" 5 | d="M292 662L292 762L392 762L392 662L292 662ZM292 462L292 562L392 562L392 462L292 462ZM692 462L692 562L792.001 562L792.001 462L692 462ZM292 362L292 262L392 262L392 362L292 362ZM492 461.999L492 361.999L592 361.999L592 461.999L492 461.999ZM492 561.999L492 661.999L592 661.999L592 561.999L492 561.999Z" 6 | fill="white" /> 7 | </svg> -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | open-pull-requests-limit: 0 8 | 9 | - package-ecosystem: 'github-actions' 10 | directory: '/' 11 | schedule: 12 | interval: 'monthly' 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | _Concise description of proposed changes_ 4 | 5 | ## Additional Information 6 | 7 | - [ ] I read the [contributing docs](/livepeer/ui-kit/blob/main/.github/CONTRIBUTING.md) (if this is your first contribution) 8 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 180 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - bug 8 | - not stale 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/cache.yml: -------------------------------------------------------------------------------- 1 | name: Bust Actions Cache 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | actions: write 8 | 9 | jobs: 10 | bust-cache: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Bust actions cache 14 | uses: actions/github-script@v7 15 | with: 16 | script: | 17 | console.log("Clearing cache...") 18 | const caches = await github.rest.actions.getActionsCacheList({ 19 | owner: context.repo.owner, 20 | repo: context.repo.repo, 21 | }) 22 | for (const cache of caches.data.actions_caches) { 23 | github.rest.actions.deleteActionsCacheById({ 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | cache_id: cache.id, 27 | }) 28 | } 29 | console.log("Cache cleared.") 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main, next] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18] 14 | pnpm-version: [8] 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 19 | fetch-depth: 0 20 | 21 | - uses: pnpm/action-setup@v4.1.0 22 | with: 23 | version: ${{ matrix.pnpm-version }} 24 | - name: Set up Node ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | cache: 'pnpm' 28 | node-version: ${{ matrix.node-version }} 29 | - name: Install Dependencies 30 | run: | 31 | pnpm i --ignore-scripts 32 | pnpm postinstall 33 | 34 | - name: Create Release Pull Request or Publish to npm 35 | id: changesets 36 | uses: changesets/action@v1 37 | with: 38 | title: 'chore: version packages' 39 | commit: 'chore: version packages' 40 | version: pnpm changeset:version 41 | publish: pnpm changeset:release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release: 7 | name: Publish Snapshot 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [18] 12 | pnpm-version: [8] 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - uses: pnpm/action-setup@v4.1.0 21 | with: 22 | version: ${{ matrix.pnpm-version }} 23 | - name: Set up Node ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | cache: 'pnpm' 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Install Dependencies 30 | run: | 31 | pnpm i --ignore-scripts 32 | pnpm postinstall 33 | 34 | - name: Publish to npm 35 | uses: seek-oss/changesets-snapshot@v0 36 | with: 37 | pre-publish: pnpm build 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '@livepeer/react@**' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | name: Tag 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Deploy to lvpr.tv 15 | run: curl -X POST -d {} $VERCEL_LVPR_DEPLOY_URL 16 | env: 17 | VERCEL_LVPR_DEPLOY_URL: ${{ secrets.VERCEL_LVPR_DEPLOY_URL }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | *.local 3 | .cache 4 | logs 5 | *.log 6 | .DS_Store 7 | *.pem 8 | *.cookie 9 | 10 | # Dependencies 11 | node_modules/ 12 | .pnp 13 | .pnp.js 14 | .yarn/install-state.gz 15 | 16 | # Build outputs 17 | dist 18 | dist-ssr 19 | build/ 20 | .turbo 21 | 22 | # TypeScript 23 | tsconfig.tsbuildinfo 24 | *.tsbuildinfo 25 | next-env.d.ts 26 | 27 | # Editor directories and files 28 | .idea 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.sw? 34 | 35 | # Local environment files 36 | .env 37 | .envrc 38 | .env*.local 39 | 40 | # Debug logs 41 | pnpm-debug.log* 42 | npm-debug.log* 43 | yarn-debug.log* 44 | yarn-error.log* 45 | 46 | # Sentry 47 | .sentryclirc 48 | 49 | # Testing 50 | coverage/ 51 | /coverage 52 | 53 | # Next.js 54 | .next/ 55 | /out/ 56 | 57 | # Vercel 58 | .vercel 59 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v18.19.0 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "webben.browserslist", 5 | "streetsidesoftware.code-spell-checker" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Arweave", "multistream"], 3 | "json.format.enable": false 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @livepeer/devx 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Livepeer Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <picture> 3 | <source media="(prefers-color-scheme: dark)" srcset=".github/content/logo-dark.svg"> 4 | <img alt="Livepeer logo" src=".github/content/logo-light.svg" width="auto" height="150"> 5 | </picture> 6 | </p> 7 | 8 | <h3 align="center"> 9 | Livepeer UI Kit 10 | <h3> 11 | 12 | ## Features 13 | 14 | - 📺 Composable components for video/audio with built-in low-latency WebRTC/HLS support, WAI-ARIA with keyboard shortcuts, and primitives for building advanced media players 15 | - 🎥 Broadcast primitives for building applications with low-latency WebRTC video streaming from the browser 16 | - 🐼 TypeScript ready 17 | - 🧪 Tests across core and React components 18 | 19 | ...and a lot more. 20 | 21 | ## Documentation 22 | 23 | For full documentation and examples, visit [docs.livepeer.org](https://docs.livepeer.org). 24 | 25 | ## Community 26 | 27 | Check out the following places for more livepeer-related content: 28 | 29 | - Join the [discussions on GitHub](https://github.com/livepeer/ui-kit/discussions) 30 | - Follow [@livepeer](https://twitter.com/livepeer) on Twitter for project updates 31 | - Jump into our [Discord](https://discord.gg/livepeer) 32 | 33 | ## Contributing 34 | 35 | If you're interested in contributing, please read the [contributing docs](/.github/CONTRIBUTING.md) **before submitting a pull request**. 36 | 37 | ## License 38 | 39 | [MIT](/LICENSE) License 40 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please go to our Discord and send a message in the support channels 6 | (without details please), where one of our team members can respond 7 | and communicate via DM for further details. 8 | -------------------------------------------------------------------------------- /apps/docs-embed/components-to-string.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "node:fs"; 2 | import * as path from "node:path"; 3 | import { glob } from "glob"; 4 | 5 | function escapeComponentContent(content: string): string { 6 | return content 7 | .replace(/\\/g, "\\\\") 8 | .replace(/`/g, "\\`") 9 | .replace(/\$\{/g, "\\${") 10 | .replace(/\"use client\";/g, "") 11 | .trim(); 12 | } 13 | 14 | function fileNameToExportName(fileName: string): string { 15 | const baseName = path.basename(fileName, path.extname(fileName)); 16 | return baseName.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&"); 17 | } 18 | 19 | async function componentsToString() { 20 | const files = await glob("src/components/**/*.ts*"); 21 | const groupedFiles: Record<string, string[]> = {}; 22 | 23 | for (const file of files) { 24 | const dirname = path.dirname(file).split(path.sep).pop(); 25 | 26 | if (dirname) { 27 | if (!groupedFiles[dirname]) { 28 | groupedFiles[dirname] = []; 29 | } 30 | groupedFiles[dirname].push(file); 31 | } 32 | } 33 | 34 | for (const [dirname, files] of Object.entries(groupedFiles)) { 35 | let exportsContent = ""; 36 | 37 | for (const file of files) { 38 | const content = await fs.readFile(file, "utf8"); 39 | const exportName = fileNameToExportName(file); 40 | exportsContent += `export const ${exportName} = \`${escapeComponentContent( 41 | content, 42 | )}\`;\n\n`; 43 | } 44 | 45 | const outputFileName = `src/lib/${dirname}-components.ts`; 46 | await fs.writeFile(outputFileName, exportsContent); 47 | console.log(`Components for ${dirname} written to ${outputFileName}`); 48 | } 49 | } 50 | 51 | componentsToString().catch((e) => console.error(e)); 52 | -------------------------------------------------------------------------------- /apps/docs-embed/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /apps/docs-embed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app-docs-embed", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "clean": "rimraf .turbo node_modules .next", 9 | "generate": "tsm components-to-string.ts", 10 | "prebuild": "pnpm run generate", 11 | "postinstall": "pnpm run generate", 12 | "start": "next start", 13 | "lint": "next lint" 14 | }, 15 | "dependencies": { 16 | "@livepeer/react": "workspace:*", 17 | "@radix-ui/react-popover": "^1.0.7", 18 | "clsx": "^2.1.1", 19 | "livepeer": "^3.3.0", 20 | "lucide-react": "^0.379.0", 21 | "next": "14.2.5", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "shiki": "^1.13.0", 25 | "sonner": "^1.4.41", 26 | "tailwind-merge": "^2.5.2", 27 | "tailwindcss-animate": "^1.0.7" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^22.3.0", 31 | "@types/react": "^18.3.2", 32 | "@types/react-dom": "^18.3.0", 33 | "autoprefixer": "^10.4.20", 34 | "postcss": "^8.4.41", 35 | "tailwindcss": "^3.4.10", 36 | "typescript": "^5.5.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/docs-embed/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/broadcast/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | export default async function PlayerLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return <div className="relative w-full h-full">{children}</div>; 9 | } 10 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/broadcast/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingIcon } from "@livepeer/react/assets"; 2 | 3 | export default function Loading() { 4 | return ( 5 | <div 6 | style={{ 7 | position: "absolute", 8 | inset: 0, 9 | display: "flex", 10 | flexDirection: "column", 11 | alignItems: "center", 12 | justifyContent: "center", 13 | gap: 20, 14 | backgroundColor: "black", 15 | backdropFilter: "blur(10px)", 16 | textAlign: "center", 17 | }} 18 | > 19 | <div 20 | style={{ 21 | position: "absolute", 22 | top: "50%", 23 | left: "50%", 24 | transform: "translate(-50%, -50%)", 25 | }} 26 | > 27 | <LoadingIcon 28 | style={{ 29 | width: "32px", 30 | height: "32px", 31 | animation: "spin infinite 1s linear", 32 | }} 33 | /> 34 | </div> 35 | </div> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/apps/docs-embed/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/docs-embed/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @keyframes spin { 6 | from { 7 | transform: rotate(0deg); 8 | } 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { Toaster } from "sonner"; 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Livepeer embedded docs", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | <html className="w-full h-full m-0" lang="en"> 20 | <body 21 | className={cn( 22 | inter.className, 23 | "dark-theme relative h-full w-full text-white bg-black m-0", 24 | )} 25 | > 26 | {children} 27 | <Toaster theme="dark" /> 28 | </body> 29 | </html> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingIcon } from "@livepeer/react/assets"; 2 | 3 | export default function Loading() { 4 | return ( 5 | <div 6 | style={{ 7 | position: "absolute", 8 | inset: 0, 9 | display: "flex", 10 | flexDirection: "column", 11 | alignItems: "center", 12 | justifyContent: "center", 13 | gap: 20, 14 | backgroundColor: "black", 15 | backdropFilter: "blur(10px)", 16 | textAlign: "center", 17 | }} 18 | > 19 | <div 20 | style={{ 21 | position: "absolute", 22 | top: "50%", 23 | left: "50%", 24 | transform: "translate(-50%, -50%)", 25 | }} 26 | > 27 | <LoadingIcon 28 | style={{ 29 | width: "32px", 30 | height: "32px", 31 | animation: "spin infinite 1s linear", 32 | }} 33 | /> 34 | </div> 35 | </div> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/player/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | export default async function PlayerLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return <div className="relative w-full h-full">{children}</div>; 9 | } 10 | -------------------------------------------------------------------------------- /apps/docs-embed/src/app/player/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingIcon } from "@livepeer/react/assets"; 2 | 3 | export default function Loading() { 4 | return ( 5 | <div 6 | style={{ 7 | position: "absolute", 8 | inset: 0, 9 | display: "flex", 10 | flexDirection: "column", 11 | alignItems: "center", 12 | justifyContent: "center", 13 | gap: 20, 14 | backgroundColor: "black", 15 | backdropFilter: "blur(10px)", 16 | textAlign: "center", 17 | }} 18 | > 19 | <div 20 | style={{ 21 | position: "absolute", 22 | top: "50%", 23 | left: "50%", 24 | transform: "translate(-50%, -50%)", 25 | }} 26 | > 27 | <LoadingIcon 28 | style={{ 29 | width: "32px", 30 | height: "32px", 31 | animation: "spin infinite 1s linear", 32 | }} 33 | /> 34 | </div> 35 | </div> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/audio.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DisableAudioIcon, EnableAudioIcon } from "@livepeer/react/assets"; 4 | import * as Broadcast from "@livepeer/react/broadcast"; 5 | import { getIngest } from "@livepeer/react/external"; 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Video 12 | title="Livestream" 13 | style={{ 14 | height: "100%", 15 | width: "100%", 16 | objectFit: "contain", 17 | }} 18 | /> 19 | 20 | <Broadcast.AudioEnabledTrigger 21 | style={{ 22 | position: "absolute", 23 | left: 20, 24 | bottom: 20, 25 | width: 25, 26 | height: 25, 27 | }} 28 | > 29 | <Broadcast.AudioEnabledIndicator asChild matcher={false}> 30 | <EnableAudioIcon /> 31 | </Broadcast.AudioEnabledIndicator> 32 | <Broadcast.AudioEnabledIndicator asChild> 33 | <DisableAudioIcon /> 34 | </Broadcast.AudioEnabledIndicator> 35 | </Broadcast.AudioEnabledTrigger> 36 | </Broadcast.Root> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/camera.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DisableVideoIcon, EnableVideoIcon } from "@livepeer/react/assets"; 4 | import * as Broadcast from "@livepeer/react/broadcast"; 5 | import { getIngest } from "@livepeer/react/external"; 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Video 12 | title="Livestream" 13 | style={{ 14 | height: "100%", 15 | width: "100%", 16 | objectFit: "contain", 17 | }} 18 | /> 19 | 20 | <Broadcast.VideoEnabledTrigger 21 | style={{ 22 | position: "absolute", 23 | left: 20, 24 | bottom: 20, 25 | width: 25, 26 | height: 25, 27 | }} 28 | > 29 | <Broadcast.VideoEnabledIndicator asChild matcher={false}> 30 | <EnableVideoIcon /> 31 | </Broadcast.VideoEnabledIndicator> 32 | <Broadcast.VideoEnabledIndicator asChild> 33 | <DisableVideoIcon /> 34 | </Broadcast.VideoEnabledIndicator> 35 | </Broadcast.VideoEnabledTrigger> 36 | </Broadcast.Root> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/container.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root aspectRatio={null} ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Container> 12 | Content in plain div with no aspect ratio 13 | </Broadcast.Container> 14 | </Broadcast.Root> 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/controls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Container> 12 | <Broadcast.Video 13 | title="Livestream" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | 17 | <Broadcast.Controls 18 | style={{ 19 | padding: 20, 20 | }} 21 | autoHide={5000} 22 | > 23 | Auto-hidden controls container for broadcast 24 | </Broadcast.Controls> 25 | </Broadcast.Container> 26 | </Broadcast.Root> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/enabled.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { EnableVideoIcon, StopIcon } from "@livepeer/react/assets"; 4 | import * as Broadcast from "@livepeer/react/broadcast"; 5 | import { getIngest } from "@livepeer/react/external"; 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Video 12 | title="Livestream" 13 | style={{ 14 | height: "100%", 15 | width: "100%", 16 | objectFit: "contain", 17 | }} 18 | /> 19 | 20 | <Broadcast.EnabledTrigger 21 | style={{ 22 | position: "absolute", 23 | left: 20, 24 | bottom: 20, 25 | width: 25, 26 | height: 25, 27 | }} 28 | > 29 | <Broadcast.EnabledIndicator asChild matcher={false}> 30 | <EnableVideoIcon /> 31 | </Broadcast.EnabledIndicator> 32 | <Broadcast.EnabledIndicator asChild> 33 | <StopIcon /> 34 | </Broadcast.EnabledIndicator> 35 | </Broadcast.EnabledTrigger> 36 | </Broadcast.Root> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Container> 12 | <Broadcast.Video 13 | title="Livestream" 14 | style={{ height: "100%", width: "100%" }} 15 | onProgress={(e) => { 16 | // we fake an error here every time there is progress 17 | 18 | setTimeout(() => { 19 | e.target.dispatchEvent(new Event("error")); 20 | }, 7000); 21 | }} 22 | /> 23 | 24 | <Broadcast.ErrorIndicator 25 | matcher="all" 26 | style={{ 27 | position: "absolute", 28 | inset: 0, 29 | height: "100%", 30 | width: "100%", 31 | display: "flex", 32 | alignItems: "center", 33 | justifyContent: "center", 34 | backgroundColor: "black", 35 | }} 36 | > 37 | An error occurred. Trying to resume broadcast... 38 | </Broadcast.ErrorIndicator> 39 | </Broadcast.Container> 40 | </Broadcast.Root> 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/fullscreen.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { 7 | EnterFullscreenIcon, 8 | ExitFullscreenIcon, 9 | } from "@livepeer/react/assets"; 10 | import { streamKey } from "./stream-key"; 11 | 12 | export default () => { 13 | return ( 14 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 15 | <Broadcast.Container> 16 | <Broadcast.Video 17 | title="Livestream" 18 | style={{ height: "100%", width: "100%" }} 19 | /> 20 | <Broadcast.FullscreenTrigger 21 | style={{ 22 | position: "absolute", 23 | left: 20, 24 | bottom: 20, 25 | width: 25, 26 | height: 25, 27 | }} 28 | > 29 | <Broadcast.FullscreenIndicator asChild matcher={false}> 30 | <EnterFullscreenIcon /> 31 | </Broadcast.FullscreenIndicator> 32 | <Broadcast.FullscreenIndicator asChild> 33 | <ExitFullscreenIcon /> 34 | </Broadcast.FullscreenIndicator> 35 | </Broadcast.FullscreenTrigger> 36 | </Broadcast.Container> 37 | </Broadcast.Root> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Container> 12 | <Broadcast.Video 13 | title="Livestream" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | 17 | <Broadcast.LoadingIndicator 18 | style={{ 19 | height: "100%", 20 | width: "100%", 21 | display: "flex", 22 | alignItems: "center", 23 | justifyContent: "center", 24 | backgroundColor: "black", 25 | }} 26 | > 27 | Loading... 28 | </Broadcast.LoadingIndicator> 29 | </Broadcast.Container> 30 | </Broadcast.Root> 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/pip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { PictureInPictureIcon } from "@livepeer/react/assets"; 7 | import { streamKey } from "./stream-key"; 8 | 9 | export default () => { 10 | return ( 11 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 12 | <Broadcast.Container> 13 | <Broadcast.Video 14 | title="Livestream" 15 | style={{ height: "100%", width: "100%" }} 16 | /> 17 | <Broadcast.PictureInPictureTrigger 18 | style={{ 19 | position: "absolute", 20 | left: 20, 21 | bottom: 20, 22 | width: 25, 23 | height: 25, 24 | }} 25 | > 26 | <PictureInPictureIcon /> 27 | </Broadcast.PictureInPictureTrigger> 28 | </Broadcast.Container> 29 | </Broadcast.Root> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/portal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { useEffect, useRef, useState } from "react"; 7 | import { streamKey } from "./stream-key"; 8 | 9 | export default () => { 10 | const parentRef = useRef<HTMLDivElement | null>(null); 11 | const [isMounted, setIsMounted] = useState(false); 12 | 13 | useEffect(() => { 14 | if (parentRef.current) { 15 | setIsMounted(true); 16 | } 17 | }, []); 18 | 19 | return ( 20 | <> 21 | <div 22 | style={{ 23 | height: 20, 24 | width: "100%", 25 | }} 26 | ref={parentRef} 27 | /> 28 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 29 | <Broadcast.Container 30 | style={{ 31 | margin: 5, 32 | borderRadius: 5, 33 | outline: "white solid 1px", 34 | overflow: "hidden", 35 | }} 36 | > 37 | <Broadcast.Video 38 | title="Livestream" 39 | style={{ height: "100%", width: "100%" }} 40 | /> 41 | 42 | {/* This is portalled outside of the parent container, 43 | which is outlined in white */} 44 | {isMounted && ( 45 | <Broadcast.Portal container={parentRef.current}> 46 | <Broadcast.Time /> 47 | </Broadcast.Portal> 48 | )} 49 | </Broadcast.Container> 50 | </Broadcast.Root> 51 | </> 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/screenshare.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | StartScreenshareIcon, 5 | StopScreenshareIcon, 6 | } from "@livepeer/react/assets"; 7 | import * as Broadcast from "@livepeer/react/broadcast"; 8 | import { getIngest } from "@livepeer/react/external"; 9 | import { streamKey } from "./stream-key"; 10 | 11 | export default () => { 12 | return ( 13 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 14 | <Broadcast.Video 15 | title="Livestream" 16 | style={{ 17 | height: "100%", 18 | width: "100%", 19 | objectFit: "contain", 20 | }} 21 | /> 22 | 23 | <Broadcast.ScreenshareTrigger 24 | style={{ 25 | position: "absolute", 26 | left: 20, 27 | bottom: 20, 28 | width: 25, 29 | height: 25, 30 | }} 31 | > 32 | <Broadcast.ScreenshareIndicator asChild matcher={false}> 33 | <StartScreenshareIcon /> 34 | </Broadcast.ScreenshareIndicator> 35 | <Broadcast.ScreenshareIndicator asChild> 36 | <StopScreenshareIcon /> 37 | </Broadcast.ScreenshareIndicator> 38 | </Broadcast.ScreenshareTrigger> 39 | </Broadcast.Root> 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/status.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | import { streamKey } from "./stream-key"; 6 | 7 | export default () => { 8 | return ( 9 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 10 | <Broadcast.Video 11 | title="Livestream" 12 | style={{ 13 | height: "100%", 14 | width: "100%", 15 | objectFit: "contain", 16 | }} 17 | /> 18 | 19 | <div 20 | style={{ 21 | position: "absolute", 22 | left: 20, 23 | bottom: 20, 24 | }} 25 | > 26 | <Broadcast.StatusIndicator matcher="idle"> 27 | Idle 28 | </Broadcast.StatusIndicator> 29 | </div> 30 | </Broadcast.Root> 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/stream-key.ts: -------------------------------------------------------------------------------- 1 | export const streamKey = "0812-lotj-3i4c-37ie"; 2 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/use-broadcast-context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { EnableVideoIcon, StopIcon } from "@livepeer/react/assets"; 4 | import * as Broadcast from "@livepeer/react/broadcast"; 5 | import { useBroadcastContext, useStore } from "@livepeer/react/broadcast"; 6 | import { getIngest } from "@livepeer/react/external"; 7 | import type { CSSProperties } from "react"; 8 | import { streamKey } from "./stream-key"; 9 | 10 | export default () => { 11 | return ( 12 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 13 | <Broadcast.Video 14 | title="Livestream" 15 | style={{ 16 | height: "100%", 17 | width: "100%", 18 | objectFit: "contain", 19 | }} 20 | /> 21 | 22 | <CurrentSource 23 | style={{ 24 | position: "absolute", 25 | left: 20, 26 | bottom: 20, 27 | }} 28 | /> 29 | 30 | <Broadcast.EnabledTrigger 31 | style={{ 32 | position: "absolute", 33 | right: 20, 34 | bottom: 20, 35 | width: 25, 36 | height: 25, 37 | }} 38 | > 39 | <Broadcast.EnabledIndicator asChild matcher={false}> 40 | <EnableVideoIcon /> 41 | </Broadcast.EnabledIndicator> 42 | <Broadcast.EnabledIndicator asChild> 43 | <StopIcon /> 44 | </Broadcast.EnabledIndicator> 45 | </Broadcast.EnabledTrigger> 46 | </Broadcast.Root> 47 | ); 48 | }; 49 | 50 | function CurrentSource({ 51 | style, 52 | __scopeBroadcast, 53 | }: Broadcast.BroadcastScopedProps<{ style?: CSSProperties }>) { 54 | const context = useBroadcastContext("CurrentSource", __scopeBroadcast); 55 | 56 | const { status } = useStore(context.store, ({ status }) => ({ 57 | status, 58 | })); 59 | 60 | return status ? ( 61 | <div style={style}> 62 | <span> 63 | Broadcast status:{" "} 64 | <span 65 | style={{ 66 | color: "#ffffffe2", 67 | }} 68 | > 69 | {status} 70 | </span> 71 | </span> 72 | </div> 73 | ) : null; 74 | } 75 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/broadcast/video.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Broadcast from "@livepeer/react/broadcast"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | import { streamKey } from "./stream-key"; 7 | 8 | export default () => { 9 | return ( 10 | <Broadcast.Root ingestUrl={getIngest(streamKey)}> 11 | <Broadcast.Container> 12 | <Broadcast.Video 13 | title="Livestream" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | </Broadcast.Container> 17 | </Broadcast.Root> 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/code/code-server.tsx: -------------------------------------------------------------------------------- 1 | import * as broadcastComponents from "@/lib/broadcast-components"; 2 | import * as playerComponents from "@/lib/player-components"; 3 | import { codeToHtml } from "@/lib/shiki"; 4 | import { notFound } from "next/navigation"; 5 | import { ExpandableCode } from "./code"; 6 | 7 | export type PlayerComponentKey = keyof typeof playerComponents; 8 | export type BroadcastComponentKey = keyof typeof broadcastComponents; 9 | 10 | export const getPlayerKeys = () => { 11 | const playerKeys = Object.keys(playerComponents) as PlayerComponentKey[]; 12 | 13 | return playerKeys; 14 | }; 15 | 16 | export const getBroadcastKeys = () => { 17 | const broadcastKeys = Object.keys( 18 | broadcastComponents, 19 | ) as BroadcastComponentKey[]; 20 | 21 | return broadcastKeys; 22 | }; 23 | 24 | export const CodeWithExampleServer = async ({ 25 | example, 26 | type, 27 | component, 28 | }: { 29 | example: React.ReactNode; 30 | type: "player" | "broadcast"; 31 | component: PlayerComponentKey | BroadcastComponentKey; 32 | }) => { 33 | const stringComponent = 34 | type === "player" 35 | ? playerComponents[component as PlayerComponentKey] 36 | : broadcastComponents[component as BroadcastComponentKey]; 37 | 38 | if (!stringComponent) { 39 | notFound(); 40 | } 41 | 42 | const result = await codeToHtml({ 43 | code: stringComponent, 44 | }); 45 | 46 | return ( 47 | <div className="flex flex-col rounded-lg"> 48 | <div className="relative flex flex-col w-full min-h-[300px] items-center justify-center overflow-hidden"> 49 | {example} 50 | </div> 51 | <ExpandableCode code={result} /> 52 | </div> 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/code/code.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import "./shiki.css"; 5 | 6 | export const ExpandableCode = ({ code }: { code: string }) => { 7 | return ( 8 | <div 9 | className={cn("flex flex-col relative overflow-hidden")} 10 | // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 11 | dangerouslySetInnerHTML={{ 12 | __html: code, 13 | }} 14 | /> 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/code/shiki.css: -------------------------------------------------------------------------------- 1 | .shiki { 2 | @apply overflow-y-auto p-5; 3 | } 4 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/clip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClipIcon } from "@livepeer/react/assets"; 4 | import { getSrc } from "@livepeer/react/external"; 5 | import * as Player from "@livepeer/react/player"; 6 | 7 | import { toast } from "sonner"; 8 | import { vodSource } from "./source"; 9 | 10 | export default () => { 11 | return ( 12 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0} clipLength={30}> 13 | <Player.Container> 14 | <Player.Video 15 | title="Agent 327" 16 | style={{ height: "100%", width: "100%" }} 17 | /> 18 | <Player.LiveIndicator matcher={false} asChild> 19 | <Player.ClipTrigger 20 | style={{ 21 | position: "absolute", 22 | left: 20, 23 | bottom: 20, 24 | }} 25 | onClip={({ playbackId, startTime, endTime }) => { 26 | toast(`Clip request for ${playbackId}`, { 27 | description: `The requested clip is from ${startTime} to ${endTime}`, 28 | }); 29 | }} 30 | > 31 | <ClipIcon style={{ width: 20, height: 20 }} /> 32 | </Player.ClipTrigger> 33 | </Player.LiveIndicator> 34 | </Player.Container> 35 | </Player.Root> 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/container.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root aspectRatio={null} src={getSrc(vodSource)}> 11 | <Player.Container> 12 | Content in plain div with no aspect ratio 13 | </Player.Container> 14 | </Player.Root> 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/controls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | 17 | <Player.Controls 18 | style={{ 19 | padding: 20, 20 | }} 21 | autoHide={5000} 22 | > 23 | Auto-hidden controls container (with click to pause/play) 24 | </Player.Controls> 25 | </Player.Container> 26 | </Player.Root> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | onProgress={(e) => { 16 | // we fake an error here every time there is progress 17 | 18 | setTimeout(() => { 19 | e.target.dispatchEvent(new Event("error")); 20 | }, 3000); 21 | }} 22 | /> 23 | <Player.ErrorIndicator 24 | matcher="all" 25 | style={{ 26 | position: "absolute", 27 | inset: 0, 28 | height: "100%", 29 | width: "100%", 30 | display: "flex", 31 | alignItems: "center", 32 | justifyContent: "center", 33 | backgroundColor: "black", 34 | }} 35 | > 36 | An error occurred. Trying to resume playback... 37 | </Player.ErrorIndicator> 38 | </Player.Container> 39 | </Player.Root> 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/fullscreen.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { 7 | EnterFullscreenIcon, 8 | ExitFullscreenIcon, 9 | } from "@livepeer/react/assets"; 10 | import { vodSource } from "./source"; 11 | 12 | export default () => { 13 | return ( 14 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 15 | <Player.Container> 16 | <Player.Video 17 | title="Agent 327" 18 | style={{ height: "100%", width: "100%" }} 19 | /> 20 | <Player.FullscreenTrigger 21 | style={{ 22 | position: "absolute", 23 | left: 20, 24 | bottom: 20, 25 | width: 25, 26 | height: 25, 27 | }} 28 | > 29 | <Player.FullscreenIndicator asChild matcher={false}> 30 | <EnterFullscreenIcon /> 31 | </Player.FullscreenIndicator> 32 | <Player.FullscreenIndicator asChild> 33 | <ExitFullscreenIcon /> 34 | </Player.FullscreenIndicator> 35 | </Player.FullscreenTrigger> 36 | </Player.Container> 37 | </Player.Root> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/getting-started.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PauseIcon, PlayIcon } from "@livepeer/react/assets"; 4 | import { getSrc } from "@livepeer/react/external"; 5 | import * as Player from "@livepeer/react/player"; 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)}> 11 | <Player.Container className="h-full w-full overflow-hidden bg-gray-950"> 12 | <Player.Video title="Live stream" className="h-full w-full" /> 13 | 14 | <Player.Controls className="flex items-center justify-center"> 15 | <Player.PlayPauseTrigger className="w-10 h-10 hover:scale-105 flex-shrink-0"> 16 | <Player.PlayingIndicator asChild matcher={false}> 17 | <PlayIcon className="w-full h-full" /> 18 | </Player.PlayingIndicator> 19 | <Player.PlayingIndicator asChild> 20 | <PauseIcon className="w-full h-full" /> 21 | </Player.PlayingIndicator> 22 | </Player.PlayPauseTrigger> 23 | </Player.Controls> 24 | </Player.Container> 25 | </Player.Root> 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/live.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | <Player.LiveIndicator 17 | matcher={false} 18 | style={{ 19 | position: "absolute", 20 | left: 20, 21 | bottom: 20, 22 | }} 23 | > 24 | STATIC ASSET 25 | </Player.LiveIndicator> 26 | </Player.Container> 27 | </Player.Root> 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | 17 | <Player.LoadingIndicator 18 | style={{ 19 | height: "100%", 20 | width: "100%", 21 | display: "flex", 22 | alignItems: "center", 23 | justifyContent: "center", 24 | backgroundColor: "black", 25 | }} 26 | > 27 | Loading... 28 | </Player.LoadingIndicator> 29 | </Player.Container> 30 | </Player.Root> 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/pip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { PictureInPictureIcon } from "@livepeer/react/assets"; 7 | import { vodSource } from "./source"; 8 | 9 | export default () => { 10 | return ( 11 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 12 | <Player.Container> 13 | <Player.Video 14 | title="Agent 327" 15 | style={{ height: "100%", width: "100%" }} 16 | /> 17 | <Player.PictureInPictureTrigger 18 | style={{ 19 | position: "absolute", 20 | left: 20, 21 | bottom: 20, 22 | width: 25, 23 | height: 25, 24 | }} 25 | > 26 | <PictureInPictureIcon /> 27 | </Player.PictureInPictureTrigger> 28 | </Player.Container> 29 | </Player.Root> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/play.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { PauseIcon, PlayIcon } from "@livepeer/react/assets"; 7 | import { vodSource } from "./source"; 8 | 9 | export default () => { 10 | return ( 11 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 12 | <Player.Container> 13 | <Player.Video 14 | title="Agent 327" 15 | style={{ height: "100%", width: "100%" }} 16 | /> 17 | <Player.PlayPauseTrigger 18 | style={{ 19 | position: "absolute", 20 | left: 20, 21 | bottom: 20, 22 | width: 25, 23 | height: 25, 24 | }} 25 | > 26 | <Player.PlayingIndicator asChild matcher={false}> 27 | <PlayIcon /> 28 | </Player.PlayingIndicator> 29 | <Player.PlayingIndicator asChild> 30 | <PauseIcon /> 31 | </Player.PlayingIndicator> 32 | </Player.PlayPauseTrigger> 33 | </Player.Container> 34 | </Player.Root> 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/portal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { useEffect, useRef, useState } from "react"; 7 | import { vodSource } from "./source"; 8 | 9 | export default () => { 10 | const parentRef = useRef<HTMLDivElement | null>(null); 11 | const [isMounted, setIsMounted] = useState(false); 12 | 13 | useEffect(() => { 14 | if (parentRef.current) { 15 | setIsMounted(true); 16 | } 17 | }, []); 18 | 19 | return ( 20 | <> 21 | <div 22 | style={{ 23 | height: 20, 24 | width: "100%", 25 | }} 26 | ref={parentRef} 27 | /> 28 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 29 | <Player.Container 30 | style={{ 31 | margin: 5, 32 | borderRadius: 5, 33 | outline: "white solid 1px", 34 | overflow: "hidden", 35 | }} 36 | > 37 | <Player.Video 38 | title="Agent 327" 39 | style={{ height: "100%", width: "100%" }} 40 | /> 41 | {/* This is portalled outside of the parent container, 42 | which is outlined in white */} 43 | {isMounted && ( 44 | <Player.Portal container={parentRef.current}> 45 | <Player.Time /> 46 | </Player.Portal> 47 | )} 48 | </Player.Container> 49 | </Player.Root> 50 | </> 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/poster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | const vodSourceWithThumbnail = { 9 | ...vodSource, 10 | meta: { 11 | ...vodSource.meta, 12 | source: [ 13 | ...vodSource.meta.source, 14 | { 15 | hrn: "Thumbnail (JPEG)", 16 | type: "image/jpeg", 17 | url: "https://ddz4ak4pa3d19.cloudfront.net/cache/7e/01/7e013b156f34e6210e53c299b2b531f5.jpg", 18 | }, 19 | ], 20 | }, 21 | }; 22 | 23 | export default () => { 24 | return ( 25 | <Player.Root src={getSrc(vodSourceWithThumbnail)} autoPlay volume={0}> 26 | <Player.Container> 27 | <Player.Video 28 | title="Agent 327" 29 | style={{ height: "100%", width: "100%" }} 30 | poster={null} 31 | /> 32 | 33 | <Player.LoadingIndicator asChild> 34 | <Player.Poster 35 | style={{ 36 | width: "100%", 37 | height: "100%", 38 | objectFit: "cover", 39 | }} 40 | /> 41 | </Player.LoadingIndicator> 42 | </Player.Container> 43 | </Player.Root> 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/seek.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | 17 | <Player.Seek 18 | style={{ 19 | position: "absolute", 20 | left: 20, 21 | right: 20, 22 | bottom: 20, 23 | height: 20, 24 | display: "flex", 25 | alignItems: "center", 26 | gap: 10, 27 | userSelect: "none", 28 | touchAction: "none", 29 | }} 30 | > 31 | <Player.Track 32 | style={{ 33 | backgroundColor: "rgba(255, 255, 255, 0.7)", 34 | position: "relative", 35 | flexGrow: 1, 36 | borderRadius: 9999, 37 | height: 2, 38 | }} 39 | > 40 | <Player.SeekBuffer 41 | style={{ 42 | position: "absolute", 43 | backgroundColor: "rgba(0, 0, 0, 0.5)", 44 | borderRadius: 9999, 45 | height: "100%", 46 | }} 47 | /> 48 | <Player.Range 49 | style={{ 50 | position: "absolute", 51 | backgroundColor: "white", 52 | borderRadius: 9999, 53 | height: "100%", 54 | }} 55 | /> 56 | </Player.Track> 57 | <Player.Thumb 58 | style={{ 59 | display: "block", 60 | width: 12, 61 | height: 12, 62 | backgroundColor: "white", 63 | borderRadius: 9999, 64 | }} 65 | /> 66 | </Player.Seek> 67 | </Player.Container> 68 | </Player.Root> 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/source.ts: -------------------------------------------------------------------------------- 1 | export const vodSource = { 2 | type: "vod", 3 | meta: { 4 | playbackPolicy: null, 5 | source: [ 6 | { 7 | hrn: "HLS (TS)", 8 | type: "html5/application/vnd.apple.mpegurl", 9 | url: "https://vod-cdn.lp-playback.studio/raw/jxf4iblf6wlsyor6526t4tcmtmqa/catalyst-vod-com/hls/f5eese9wwl88k4g8/index.m3u8", 10 | }, 11 | ], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/time.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | <Player.Time 17 | style={{ 18 | position: "absolute", 19 | left: 20, 20 | bottom: 20, 21 | height: 25, 22 | fontVariant: "tabular-nums", 23 | }} 24 | /> 25 | </Player.Container> 26 | </Player.Root> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/use-media-context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | import { 6 | type MediaScopedProps, 7 | useMediaContext, 8 | useStore, 9 | } from "@livepeer/react/player"; 10 | import type { CSSProperties } from "react"; 11 | import { vodSource } from "./source"; 12 | 13 | export default () => { 14 | return ( 15 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 16 | <Player.Container className="h-full w-full overflow-hidden bg-gray-950"> 17 | <Player.Video title="Live stream" className="h-full w-full" /> 18 | 19 | <CurrentSource 20 | style={{ 21 | position: "absolute", 22 | left: 20, 23 | bottom: 20, 24 | }} 25 | /> 26 | </Player.Container> 27 | </Player.Root> 28 | ); 29 | }; 30 | 31 | function CurrentSource({ 32 | style, 33 | __scopeMedia, 34 | }: MediaScopedProps<{ style?: CSSProperties }>) { 35 | const context = useMediaContext("CurrentSource", __scopeMedia); 36 | 37 | const { currentSource } = useStore(context.store, ({ currentSource }) => ({ 38 | currentSource, 39 | })); 40 | 41 | return currentSource ? ( 42 | <div style={style}> 43 | <span> 44 | Playback type:{" "} 45 | <span 46 | style={{ 47 | color: "#ffffffe2", 48 | }} 49 | > 50 | {currentSource?.type} 51 | </span> 52 | </span> 53 | </div> 54 | ) : null; 55 | } 56 | -------------------------------------------------------------------------------- /apps/docs-embed/src/components/player/video.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getSrc } from "@livepeer/react/external"; 4 | import * as Player from "@livepeer/react/player"; 5 | 6 | import { vodSource } from "./source"; 7 | 8 | export default () => { 9 | return ( 10 | <Player.Root src={getSrc(vodSource)} autoPlay volume={0}> 11 | <Player.Container> 12 | <Player.Video 13 | title="Agent 327" 14 | style={{ height: "100%", width: "100%" }} 15 | /> 16 | </Player.Container> 17 | </Player.Root> 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/docs-embed/src/lib/code-components.ts: -------------------------------------------------------------------------------- 1 | export const code = `import { cn } from "@/lib/utils"; 2 | import "./shiki.css"; 3 | 4 | export const ExpandableCode = ({ code }: { code: string }) => { 5 | return ( 6 | <div 7 | className={cn("flex flex-col relative overflow-hidden")} 8 | // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 9 | dangerouslySetInnerHTML={{ 10 | __html: code, 11 | }} 12 | /> 13 | ); 14 | };`; 15 | 16 | export const code_server = `import * as broadcastComponents from "@/lib/broadcast-components"; 17 | import * as playerComponents from "@/lib/player-components"; 18 | import { codeToHtml } from "@/lib/shiki"; 19 | import { notFound } from "next/navigation"; 20 | import { ExpandableCode } from "./code"; 21 | 22 | export type PlayerComponentKey = keyof typeof playerComponents; 23 | export type BroadcastComponentKey = keyof typeof broadcastComponents; 24 | 25 | export const getPlayerKeys = () => { 26 | const playerKeys = Object.keys(playerComponents) as PlayerComponentKey[]; 27 | 28 | return playerKeys; 29 | }; 30 | 31 | export const getBroadcastKeys = () => { 32 | const broadcastKeys = Object.keys( 33 | broadcastComponents, 34 | ) as BroadcastComponentKey[]; 35 | 36 | return broadcastKeys; 37 | }; 38 | 39 | export const CodeWithExampleServer = async ({ 40 | example, 41 | type, 42 | component, 43 | }: { 44 | example: React.ReactNode; 45 | type: "player" | "broadcast"; 46 | component: PlayerComponentKey | BroadcastComponentKey; 47 | }) => { 48 | const stringComponent = 49 | type === "player" 50 | ? playerComponents[component as PlayerComponentKey] 51 | : broadcastComponents[component as BroadcastComponentKey]; 52 | 53 | if (!stringComponent) { 54 | notFound(); 55 | } 56 | 57 | const result = await codeToHtml({ 58 | code: stringComponent, 59 | }); 60 | 61 | return ( 62 | <div className="flex flex-col rounded-lg"> 63 | <div className="relative flex flex-col w-full min-h-[300px] items-center justify-center overflow-hidden"> 64 | {example} 65 | </div> 66 | <ExpandableCode code={result} /> 67 | </div> 68 | ); 69 | };`; 70 | -------------------------------------------------------------------------------- /apps/docs-embed/src/lib/livepeer.ts: -------------------------------------------------------------------------------- 1 | import { Livepeer } from "livepeer"; 2 | 3 | export const livepeer = new Livepeer({ 4 | apiKey: process.env.STUDIO_API_KEY ?? "", 5 | }); 6 | -------------------------------------------------------------------------------- /apps/docs-embed/src/lib/shiki.ts: -------------------------------------------------------------------------------- 1 | import { getHighlighter } from "shiki/bundle/web"; 2 | 3 | const highlighterPromise = getHighlighter({ 4 | themes: ["vitesse-black"], 5 | langs: ["tsx", "css"], 6 | }); 7 | 8 | export const codeToHtml = async ({ code }: { code: string }) => { 9 | const highlighter = await highlighterPromise; 10 | 11 | return highlighter.codeToHtml(code, { 12 | lang: "tsx", 13 | theme: "vitesse-black", 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/docs-embed/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Booleanish = "0" | "1" | "" | "true" | "false"; 2 | -------------------------------------------------------------------------------- /apps/docs-embed/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import type { Booleanish } from "./types"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function coerceToBoolean( 10 | value: Booleanish | undefined, 11 | defaultValue: boolean, 12 | ): boolean { 13 | switch (value) { 14 | case "1": 15 | case "": 16 | case "true": 17 | return true; 18 | case "0": 19 | case "false": 20 | return false; 21 | default: 22 | return defaultValue; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/docs-embed/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | plugins: [require("tailwindcss-animate")], 10 | }; 11 | export default config; 12 | -------------------------------------------------------------------------------- /apps/docs-embed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /apps/lvpr-tv/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | async headers() { 5 | return [ 6 | { 7 | source: "/(.*)", 8 | headers: [ 9 | { 10 | key: "Content-Security-Policy", 11 | value: "frame-ancestors *", 12 | }, 13 | { 14 | key: "Access-Control-Allow-Origin", 15 | value: "*", 16 | }, 17 | { 18 | key: "Access-Control-Allow-Methods", 19 | value: "GET, POST, PUT, DELETE, OPTIONS", 20 | }, 21 | { 22 | key: "Access-Control-Allow-Headers", 23 | value: "Content-Type, Authorization", 24 | }, 25 | ], 26 | }, 27 | ]; 28 | }, 29 | }; 30 | 31 | module.exports = nextConfig; 32 | -------------------------------------------------------------------------------- /apps/lvpr-tv/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app-lvpr-tv", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "clean": "rimraf .turbo node_modules .next", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@livepeer/react": "workspace:*", 14 | "@radix-ui/react-popover": "^1.0.7", 15 | "clsx": "^2.1.1", 16 | "livepeer": "^3.3.0", 17 | "lucide-react": "^0.379.0", 18 | "next": "14.2.5", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "sonner": "^1.4.41", 22 | "tailwind-merge": "^2.5.2", 23 | "tailwindcss-animate": "^1.0.7" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^22.3.0", 27 | "@types/react": "^18.3.2", 28 | "@types/react-dom": "^18.3.0", 29 | "autoprefixer": "^10.4.20", 30 | "postcss": "^8.4.41", 31 | "tailwindcss": "^3.4.10", 32 | "typescript": "^5.5.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/lvpr-tv/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/app/broadcast/[key]/page.tsx: -------------------------------------------------------------------------------- 1 | import { BroadcastWithControls } from "@/components/broadcast/Broadcast"; 2 | import type { Booleanish } from "@/lib/types"; 3 | import { coerceToBoolean } from "@/lib/utils"; 4 | import { getIngest } from "@livepeer/react/external"; 5 | 6 | type BroadcastSearchParams = { 7 | forceEnabled?: Booleanish; 8 | hideEnabled?: Booleanish; 9 | idealWidth?: string | number; 10 | idealHeight?: string | number; 11 | }; 12 | 13 | export default async function BroadcastPage({ 14 | params, 15 | searchParams, 16 | }: { params: { key?: string }; searchParams: Partial<BroadcastSearchParams> }) { 17 | const ingestUrl = getIngest(params.key, { 18 | baseUrl: 19 | process.env.NEXT_PUBLIC_WEBRTC_INGEST_BASE_URL ?? 20 | "https://playback.livepeer.studio/webrtc", 21 | }); 22 | 23 | return ( 24 | <main className="absolute inset-0 gap-2 flex flex-col justify-center items-center bg-black"> 25 | <BroadcastWithControls 26 | ingestUrl={ingestUrl} 27 | forceEnabled={coerceToBoolean(searchParams?.forceEnabled, true)} 28 | hideEnabled={coerceToBoolean(searchParams?.hideEnabled, false)} 29 | video={ 30 | searchParams.idealHeight || searchParams.idealWidth 31 | ? { 32 | width: searchParams.idealWidth 33 | ? Number(searchParams.idealWidth) 34 | : undefined, 35 | height: searchParams.idealHeight 36 | ? Number(searchParams.idealHeight) 37 | : undefined, 38 | } 39 | : undefined 40 | } 41 | /> 42 | </main> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/apps/lvpr-tv/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/lvpr-tv/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { Toaster } from "sonner"; 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Livepeer TV", 11 | description: "Video hosted on Livepeer Studio.", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | <html className="w-full h-full m-0" lang="en"> 21 | <body 22 | className={cn( 23 | inter.className, 24 | "dark-theme relative h-full w-full text-white bg-black m-0", 25 | )} 26 | > 27 | {children} 28 | <Toaster theme="dark" /> 29 | </body> 30 | </html> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/components/PlayerErrorMonitor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type MediaScopedProps, 5 | useMediaContext, 6 | useStore, 7 | } from "@livepeer/react/player"; 8 | import { useEffect, useRef } from "react"; 9 | 10 | interface PlayerEventDetail { 11 | type: string; 12 | message: string; 13 | error?: unknown; 14 | } 15 | 16 | function dispatchPlayerEvent(type: string, detail?: PlayerEventDetail) { 17 | if (typeof window !== "undefined") { 18 | window.dispatchEvent(new CustomEvent(type, { detail })); 19 | } 20 | } 21 | 22 | export function PlayerErrorMonitor({ 23 | __scopeMedia, 24 | }: MediaScopedProps<Record<string, never>>) { 25 | const context = useMediaContext("PlayerErrorMonitor", __scopeMedia); 26 | const previousError = useRef<unknown>(null); 27 | 28 | const { error } = useStore(context.store, ({ error }) => ({ 29 | error, 30 | })); 31 | 32 | useEffect(() => { 33 | if (error && error !== previousError.current) { 34 | previousError.current = error; 35 | 36 | let errorType = "general"; 37 | let message = "Player error occurred"; 38 | 39 | const errorObj = error as { type?: string }; 40 | 41 | if (errorObj.type === "offline") { 42 | errorType = "offline"; 43 | message = "Stream is offline"; 44 | dispatchPlayerEvent("lvpr-player-offline", { 45 | type: errorType, 46 | message, 47 | error, 48 | }); 49 | } else if (errorObj.type === "access-control") { 50 | errorType = "access-control"; 51 | message = "Stream is private - access denied"; 52 | dispatchPlayerEvent("lvpr-player-access-control", { 53 | type: errorType, 54 | message, 55 | error, 56 | }); 57 | } else { 58 | dispatchPlayerEvent("lvpr-player-error", { 59 | type: errorType, 60 | message, 61 | error, 62 | }); 63 | } 64 | } 65 | }, [error]); 66 | 67 | return null; 68 | } 69 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/components/player/Clip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | 5 | import * as Player from "@livepeer/react/player"; 6 | 7 | import { ClipIcon, LoadingIcon } from "@livepeer/react/assets"; 8 | import type { ClipPayload } from "livepeer/models/components"; 9 | import { useCallback, useTransition } from "react"; 10 | import { createClip } from "./actions"; 11 | 12 | export function Clip({ className }: { className?: string }) { 13 | const [isPending, startTransition] = useTransition(); 14 | 15 | const createClipComposed = useCallback((opts: ClipPayload) => { 16 | startTransition(async () => { 17 | const result = await createClip(opts); 18 | 19 | if (result.success) { 20 | toast.success( 21 | <span> 22 | { 23 | "You have created a new clip - in a few minutes, you will be able to view it at " 24 | } 25 | <a 26 | href={`/?v=${result.playbackId}`} 27 | target="_blank" 28 | rel="noreferrer" 29 | className="font-semibold" 30 | > 31 | this link 32 | </a> 33 | {"."} 34 | </span>, 35 | ); 36 | } else { 37 | toast.error( 38 | "Failed to create a clip. Please try again in a few seconds.", 39 | ); 40 | } 41 | }); 42 | }, []); 43 | 44 | return ( 45 | <Player.LiveIndicator className={className} asChild> 46 | <Player.ClipTrigger 47 | onClip={createClipComposed} 48 | disabled={isPending} 49 | className="hover:scale-110 transition-all flex-shrink-0" 50 | > 51 | {isPending ? ( 52 | <LoadingIcon className="h-full w-full animate-spin" /> 53 | ) : ( 54 | <ClipIcon className="w-full h-full" /> 55 | )} 56 | </Player.ClipTrigger> 57 | </Player.LiveIndicator> 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/components/player/CurrentSource.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type MediaScopedProps, 5 | useMediaContext, 6 | useStore, 7 | } from "@livepeer/react/player"; 8 | 9 | const CURRENT_SOURCE_NAME = "CurrentSource"; 10 | 11 | export function CurrentSource({ 12 | className, 13 | __scopeMedia, 14 | }: MediaScopedProps<{ className?: string }>) { 15 | const context = useMediaContext(CURRENT_SOURCE_NAME, __scopeMedia); 16 | 17 | const { currentSource } = useStore(context.store, ({ currentSource }) => ({ 18 | currentSource, 19 | })); 20 | 21 | return currentSource ? ( 22 | <div className={className}> 23 | <div className="flex gap-4 select-none items-center group"> 24 | <span className="flex-shrink-0 line-clamp-1">Playback type:</span> 25 | <span className="text-xs text-white/80">{currentSource?.type}</span> 26 | </div> 27 | <div className="flex gap-4 select-none items-center group"> 28 | <span className="flex-shrink-0 line-clamp-1">Source path:</span> 29 | <span className="text-xs text-white/80 line-clamp-1"> 30 | {new URL(currentSource.src)?.pathname + 31 | new URL(currentSource.src)?.search} 32 | </span> 33 | </div> 34 | </div> 35 | ) : null; 36 | } 37 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/components/player/ForceError.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { 5 | type MediaScopedProps, 6 | useMediaContext, 7 | useStore, 8 | } from "@livepeer/react/player"; 9 | 10 | const FORCE_ERROR_NAME = "ForceError"; 11 | 12 | export function ForceError({ 13 | className, 14 | __scopeMedia, 15 | }: MediaScopedProps<{ className?: string }>) { 16 | const context = useMediaContext(FORCE_ERROR_NAME, __scopeMedia); 17 | 18 | const { onError } = useStore(context.store, ({ __controlsFunctions }) => ({ 19 | onError: __controlsFunctions.onError, 20 | })); 21 | 22 | return ( 23 | <button 24 | className={cn( 25 | "bg-white/10 text-sm rounded-md hover:bg-white/20 px-3 py-2", 26 | className, 27 | )} 28 | title="Simulate a playback error in the video element above" 29 | type="button" 30 | onClick={() => 31 | onError( 32 | new Error( 33 | "This is a simulated error, to show how fallback works when errors are encountered in playback.", 34 | ), 35 | ) 36 | } 37 | > 38 | Simulate a playback error 39 | </button> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/components/player/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { livepeer } from "@/lib/livepeer"; 4 | import type { ClipPayload } from "livepeer/models/components"; 5 | 6 | export const createClip = async (opts: ClipPayload) => { 7 | try { 8 | const result = await livepeer.stream.createClip({ 9 | ...opts, 10 | }); 11 | 12 | if (!result.data?.asset?.playbackId) { 13 | return { success: false, error: "PLAYBACK_ID_MISSING" } as const; 14 | } 15 | 16 | return { 17 | success: true, 18 | playbackId: result.data?.asset?.playbackId, 19 | } as const; 20 | } catch (e) { 21 | console.error(e); 22 | 23 | return { success: false, error: "CLIP_ERROR" } as const; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/lib/livepeer.ts: -------------------------------------------------------------------------------- 1 | import { Livepeer } from "livepeer"; 2 | 3 | if ( 4 | !process.env.NEXT_PUBLIC_STUDIO_API_KEY || 5 | !process.env.NEXT_PUBLIC_STUDIO_BASE_URL 6 | ) { 7 | throw new Error("Missing studio API key or base URL"); 8 | } 9 | 10 | export const livepeer = new Livepeer({ 11 | apiKey: process.env.NEXT_PUBLIC_STUDIO_API_KEY, 12 | serverURL: process.env.NEXT_PUBLIC_STUDIO_BASE_URL, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Booleanish = "0" | "1" | "" | "true" | "false"; 2 | -------------------------------------------------------------------------------- /apps/lvpr-tv/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import type { Booleanish } from "./types"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function coerceToBoolean( 10 | value: Booleanish | undefined, 11 | defaultValue: boolean, 12 | ): boolean { 13 | switch (value) { 14 | case "1": 15 | case "": 16 | case "true": 17 | return true; 18 | case "0": 19 | case "false": 20 | return false; 21 | default: 22 | return defaultValue; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/lvpr-tv/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | plugins: [require("tailwindcss-animate")], 10 | }; 11 | export default config; 12 | -------------------------------------------------------------------------------- /apps/lvpr-tv/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.2/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "suspicious": { 11 | "noExplicitAny": "warn" 12 | } 13 | } 14 | }, 15 | "formatter": { 16 | "indentStyle": "space" 17 | }, 18 | "vcs": { 19 | "enabled": true, 20 | "clientKind": "git", 21 | "useIgnoreFile": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Livepeer UI Kit Examples 2 | 3 | Welcome to the examples for Livepeer UI Kit. Here you'll find various examples of using Livepeer UI Kit with different frameworks and to accomplish certain tasks. 4 | 5 | The packages which are prefixed with an underscore (`_`) are development-related projects used to test code locally while developing. These contain unreliable code that is subject to change. 6 | -------------------------------------------------------------------------------- /examples/next-pages/.env.example: -------------------------------------------------------------------------------- 1 | LIVEPEER_JWT_PRIVATE_KEY="" 2 | LIVEPEER_JWT_PUBLIC_KEY="" 3 | -------------------------------------------------------------------------------- /examples/next-pages/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/next-pages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-pages", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "clean": "rimraf .turbo node_modules .next", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@livepeer/core-web": "workspace:*", 14 | "@livepeer/react": "workspace:*", 15 | "@radix-ui/react-popover": "^1.0.7", 16 | "clsx": "^2.1.1", 17 | "livepeer": "^3.3.0", 18 | "lucide-react": "^0.379.0", 19 | "next": "14.2.5", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "sonner": "^1.4.41", 23 | "tailwind-merge": "^2.5.2", 24 | "tailwindcss-animate": "^1.0.7", 25 | "zod": "^3.23.8" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^22.3.0", 29 | "@types/react": "^18.3.2", 30 | "@types/react-dom": "^18.3.0", 31 | "autoprefixer": "^10.4.20", 32 | "postcss": "^8.4.41", 33 | "tailwindcss": "^3.4.10", 34 | "typescript": "^5.5.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/next-pages/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/next-pages/src/components/Clip.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | 3 | import * as Player from "@livepeer/react/player"; 4 | 5 | import { ClipIcon, LoadingIcon } from "@livepeer/react/assets"; 6 | import type { ClipPayload } from "livepeer/models/components"; 7 | import { useCallback, useState } from "react"; 8 | 9 | export function Clip({ className }: { className?: string }) { 10 | const [pending, setIsPending] = useState(false); 11 | 12 | const createClipComposed = useCallback(async (opts: ClipPayload) => { 13 | setIsPending(true); 14 | try { 15 | const result = await fetch("/api/clip", { 16 | method: "POST", 17 | body: JSON.stringify(opts), 18 | headers: { 19 | "content-type": "application/json", 20 | }, 21 | }); 22 | 23 | if (result.ok) { 24 | const response = await result.json(); 25 | 26 | toast.success( 27 | <span> 28 | { 29 | "You have created a new clip - in a few minutes, you will be able to view it at " 30 | } 31 | <a 32 | href={`https://lvpr.tv?v=${response.playbackId}`} 33 | target="_blank" 34 | rel="noreferrer" 35 | className="font-semibold" 36 | > 37 | this link 38 | </a> 39 | {"."} 40 | </span>, 41 | ); 42 | } else { 43 | toast.error( 44 | "Failed to create a clip. Please try again in a few seconds.", 45 | ); 46 | } 47 | } catch (e) { 48 | console.error(e); 49 | } finally { 50 | setIsPending(false); 51 | } 52 | }, []); 53 | 54 | return ( 55 | <Player.LiveIndicator className={className} asChild> 56 | <Player.ClipTrigger 57 | onClip={createClipComposed} 58 | disabled={pending} 59 | className="hover:scale-110 transition-all flex-shrink-0" 60 | > 61 | {pending ? ( 62 | <LoadingIcon className="h-full w-full animate-spin" /> 63 | ) : ( 64 | <ClipIcon className="w-full h-full" /> 65 | )} 66 | </Player.ClipTrigger> 67 | </Player.LiveIndicator> 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /examples/next-pages/src/lib/livepeer.ts: -------------------------------------------------------------------------------- 1 | import { Livepeer } from "livepeer"; 2 | 3 | export const livepeer = new Livepeer({ 4 | apiKey: process.env.STUDIO_API_KEY ?? "", 5 | }); 6 | -------------------------------------------------------------------------------- /examples/next-pages/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "sonner"; 2 | import "./globals.css"; 3 | 4 | import type { AppProps } from "next/app"; 5 | 6 | export default function MyApp({ Component, pageProps }: AppProps) { 7 | return ( 8 | <> 9 | <Component {...pageProps} /> 10 | <Toaster theme="dark" /> 11 | </> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | <Html lang="en"> 6 | <Head /> 7 | <body className="dark-theme text-white bg-black min-h-screen-safe pb-[env(safe-area-inset-bottom)]"> 8 | <Main /> 9 | <NextScript /> 10 | </body> 11 | </Html> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/alternative-player.tsx: -------------------------------------------------------------------------------- 1 | import { addMediaMetrics } from "@livepeer/core-web/browser"; 2 | 3 | import { useEffect, useRef } from "react"; 4 | 5 | export default function Page() { 6 | const ref = useRef<HTMLVideoElement | null>(null); 7 | 8 | useEffect(() => { 9 | const videoElement = ref.current; 10 | 11 | const handlePause = () => { 12 | if (videoElement) { 13 | videoElement.currentTime = 0; 14 | } 15 | }; 16 | 17 | videoElement?.addEventListener("pause", handlePause); 18 | 19 | const { destroy } = addMediaMetrics(videoElement, { 20 | disableProgressListener: true, 21 | }); 22 | 23 | // Cleanup function to remove event listener and destroy metrics when component unmounts 24 | return () => { 25 | videoElement?.removeEventListener("pause", handlePause); 26 | destroy(); 27 | }; 28 | }, []); 29 | 30 | return ( 31 | <main className="flex flex-col md:flex-row min-h-screen justify-center items-center bg-black gap-12 p-10"> 32 | <video 33 | controls 34 | muted 35 | ref={ref} 36 | src="https://vod-cdn.lp-playback.studio/raw/jxf4iblf6wlsyor6526t4tcmtmqa/catalyst-vod-com/hls/0b79ukgd9vf7t0ae/static2160p0.mp4" 37 | autoPlay 38 | /> 39 | </main> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/api/jwt.ts: -------------------------------------------------------------------------------- 1 | import { signAccessJwt } from "@livepeer/react/crypto"; 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | export type CreateSignedPlaybackBody = { 5 | playbackId: string; 6 | }; 7 | 8 | export type CreateSignedPlaybackResponse = { 9 | success: true; 10 | token: string; 11 | }; 12 | 13 | export type ApiError = { 14 | success: false; 15 | message: string; 16 | }; 17 | 18 | const livepeerPrivateKey = process.env.LIVEPEER_JWT_PRIVATE_KEY ?? ""; 19 | const livepeerPublicKey = process.env.LIVEPEER_JWT_PUBLIC_KEY ?? ""; 20 | 21 | if (process.env.VERCEL_ENV && (!livepeerPrivateKey || !livepeerPublicKey)) { 22 | throw new Error("No private/public key configured."); 23 | } 24 | 25 | export default async ( 26 | req: NextApiRequest, 27 | res: NextApiResponse<CreateSignedPlaybackResponse | ApiError>, 28 | ) => { 29 | try { 30 | const method = req.method; 31 | 32 | if (method === "POST") { 33 | const { playbackId }: CreateSignedPlaybackBody = req.body; 34 | 35 | if (!playbackId) { 36 | return res 37 | .status(400) 38 | .json({ success: false, message: "Missing data in body." }); 39 | } 40 | 41 | // do some auth check before issuing this JWT (e.g. checkUserHasAccess(playbackId, userId)) 42 | 43 | // we sign the JWT and return it to the user 44 | const token = await signAccessJwt({ 45 | privateKey: livepeerPrivateKey, 46 | publicKey: livepeerPublicKey, 47 | issuer: "https://docs.livepeer.org", 48 | // playback ID to include in the JWT 49 | playbackId, 50 | // expire the JWT in 20 seconds 51 | expiration: 20, 52 | // custom metadata to include 53 | custom: { 54 | userId: "user-id", 55 | }, 56 | }); 57 | 58 | return res.status(200).json({ success: true, token }); 59 | } 60 | 61 | res.setHeader("Allow", ["POST"]); 62 | return res.status(405).end(`Method ${method} Not Allowed`); 63 | } catch (err) { 64 | console.error(err); 65 | return res 66 | .status(500) 67 | .json({ success: false, message: (err as Error)?.message ?? "Error" }); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /examples/next-pages/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerWithControls } from "@/components/Player"; 2 | import { livepeer } from "@/lib/livepeer"; 3 | import { getSrc } from "@livepeer/react/external"; 4 | 5 | import type { InferGetServerSidePropsType } from "next"; 6 | 7 | const playbackId = "9491n0th73i8hlpi"; 8 | 9 | export const getServerSideProps = async () => { 10 | const playbackInfo = await livepeer.playback.get(playbackId); 11 | 12 | const src = getSrc(playbackInfo.playbackInfo); 13 | 14 | return { props: { src } }; 15 | }; 16 | 17 | export default function Page({ 18 | src, 19 | }: InferGetServerSidePropsType<typeof getServerSideProps>) { 20 | return ( 21 | <main className="flex flex-col md:flex-row min-h-screen justify-center items-center bg-black gap-12 p-10"> 22 | <PlayerWithControls src={src} playbackId={playbackId} /> 23 | </main> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /examples/next-pages/src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/examples/next-pages/src/public/favicon.ico -------------------------------------------------------------------------------- /examples/next-pages/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | plugins: [require("tailwindcss-animate")], 9 | }; 10 | export default config; 11 | -------------------------------------------------------------------------------- /examples/next-pages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | }, 25 | "forceConsistentCasingInFileNames": true 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /examples/next/.env.example: -------------------------------------------------------------------------------- 1 | STUDIO_API_KEY="" 2 | STUDIO_BASE_URL="" 3 | LIVEPEER_JWT_PRIVATE_KEY="" 4 | LIVEPEER_JWT_PUBLIC_KEY="" 5 | -------------------------------------------------------------------------------- /examples/next/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="next" /> 2 | /// <reference types="next/image-types/global" /> 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/next/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "clean": "rimraf .turbo node_modules .next", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@livepeer/react": "workspace:*", 14 | "@radix-ui/react-popover": "^1.0.7", 15 | "clsx": "^2.1.1", 16 | "livepeer": "^3.3.0", 17 | "lucide-react": "^0.379.0", 18 | "next": "14.2.5", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "sonner": "^1.4.41", 22 | "tailwind-merge": "^2.5.2", 23 | "tailwindcss-animate": "^1.0.7", 24 | "zod": "^3.23.8" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^22.3.0", 28 | "@types/react": "^18.3.2", 29 | "@types/react-dom": "^18.3.0", 30 | "autoprefixer": "^10.4.20", 31 | "postcss": "^8.4.41", 32 | "tailwindcss": "^3.4.10", 33 | "typescript": "^5.5.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/next/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/next/src/app/broadcast/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { BroadcastWithControls } from "./Broadcast"; 3 | 4 | export default async function Home() { 5 | return ( 6 | <main className="flex relative min-h-screen flex-col items-center bg-black gap-8 py-12 md:py-8 p-4"> 7 | <Link 8 | className="absolute font-medium hover:text-white/70 text-white/80 text-sm top-4 left-6" 9 | href="/" 10 | > 11 | Go back 12 | </Link> 13 | <div className="flex gap-2 max-w-lg text-center flex-col"> 14 | <span className="text-2xl font-semibold"> 15 | Livepeer UI Kit Broadcast 16 | </span> 17 | <span className="text-sm text-white/90"> 18 | The component below demonstrates how to compose a Broadcast with the 19 | standard controls found on most broadcast interfaces. 20 | </span> 21 | </div> 22 | 23 | <span className="h-px w-full max-w-md bg-gradient-to-r from-white/5 via-white/60 to-white/5" /> 24 | 25 | <BroadcastWithControls /> 26 | </main> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/next/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/examples/next/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/next/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /examples/next/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { Toaster } from "sonner"; 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Livepeer w/ App Router", 11 | description: "Livepeer UI Kit with the Next.JS App Router", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | <html lang="en"> 21 | <body 22 | className={cn( 23 | inter.className, 24 | "dark-theme text-white bg-black min-h-screen-safe pb-[env(safe-area-inset-bottom)]", 25 | )} 26 | > 27 | {children} 28 | <Toaster theme="dark" /> 29 | </body> 30 | </html> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/next/src/app/player/[type]/Clip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | 5 | import * as Player from "@livepeer/react/player"; 6 | 7 | import { ClipIcon, LoadingIcon } from "@livepeer/react/assets"; 8 | import type { ClipPayload } from "livepeer/models/components"; 9 | import { useCallback, useTransition } from "react"; 10 | import { createClip } from "./actions"; 11 | 12 | export function Clip({ className }: { className?: string }) { 13 | const [isPending, startTransition] = useTransition(); 14 | 15 | const createClipComposed = useCallback(async (opts: ClipPayload) => { 16 | startTransition(async () => { 17 | const result = await createClip(opts); 18 | if (result.success) { 19 | toast.success( 20 | <span> 21 | { 22 | "You have created a new clip - in a few minutes, you will be able to view it at " 23 | } 24 | <a 25 | href={`/player/${result.playbackId}`} 26 | target="_blank" 27 | rel="noreferrer" 28 | className="font-semibold" 29 | > 30 | this link 31 | </a> 32 | {"."} 33 | </span>, 34 | ); 35 | } else { 36 | toast.error( 37 | "Failed to create a clip. Please try again in a few seconds.", 38 | ); 39 | } 40 | }); 41 | }, []); 42 | 43 | return ( 44 | <Player.LiveIndicator className={className} asChild> 45 | <Player.ClipTrigger 46 | onClip={createClipComposed} 47 | disabled={isPending} 48 | className="hover:scale-110 transition-all flex-shrink-0" 49 | > 50 | {isPending ? ( 51 | <LoadingIcon className="h-full w-full animate-spin" /> 52 | ) : ( 53 | <ClipIcon className="w-full h-full" /> 54 | )} 55 | </Player.ClipTrigger> 56 | </Player.LiveIndicator> 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /examples/next/src/app/player/[type]/CurrentSource.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type MediaScopedProps, 5 | useMediaContext, 6 | useStore, 7 | } from "@livepeer/react/player"; 8 | 9 | const CURRENT_SOURCE_NAME = "CurrentSource"; 10 | 11 | export function CurrentSource({ 12 | className, 13 | __scopeMedia, 14 | }: MediaScopedProps<{ className?: string }>) { 15 | const context = useMediaContext(CURRENT_SOURCE_NAME, __scopeMedia); 16 | 17 | const { currentSource } = useStore(context.store, ({ currentSource }) => ({ 18 | currentSource, 19 | })); 20 | 21 | return currentSource ? ( 22 | <div className={className}> 23 | <div className="flex gap-4 select-none items-center group"> 24 | <span className="flex-shrink-0 line-clamp-1">Playback type:</span> 25 | <span className="text-xs text-white/80">{currentSource?.type}</span> 26 | </div> 27 | <div className="flex gap-4 select-none items-center group"> 28 | <span className="flex-shrink-0 line-clamp-1">Source path:</span> 29 | <span className="text-xs text-white/80 line-clamp-1"> 30 | {new URL(currentSource.src)?.pathname + 31 | new URL(currentSource.src)?.search} 32 | </span> 33 | </div> 34 | </div> 35 | ) : null; 36 | } 37 | -------------------------------------------------------------------------------- /examples/next/src/app/player/[type]/ForceError.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { 5 | type MediaScopedProps, 6 | useMediaContext, 7 | useStore, 8 | } from "@livepeer/react/player"; 9 | 10 | const FORCE_ERROR_NAME = "ForceError"; 11 | 12 | export function ForceError({ 13 | className, 14 | __scopeMedia, 15 | }: MediaScopedProps<{ className?: string }>) { 16 | const context = useMediaContext(FORCE_ERROR_NAME, __scopeMedia); 17 | 18 | const { onError } = useStore(context.store, ({ __controlsFunctions }) => ({ 19 | onError: __controlsFunctions.onError, 20 | })); 21 | 22 | return ( 23 | <button 24 | className={cn( 25 | "bg-white/10 text-sm rounded-md hover:bg-white/20 px-3 py-2", 26 | className, 27 | )} 28 | title="Simulate a playback error in the video element above" 29 | type="button" 30 | onClick={() => 31 | onError( 32 | new Error( 33 | "This is a simulated error, to show how fallback works when errors are encountered in playback.", 34 | ), 35 | ) 36 | } 37 | > 38 | Simulate a playback error 39 | </button> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /examples/next/src/app/player/[type]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createStreamClip } from "@/app/livepeer"; 4 | import type { ClipPayload } from "livepeer/models/components"; 5 | import z from "zod"; 6 | 7 | const isValidUnixTimestamp = (timestamp: number) => { 8 | const now = Date.now(); 9 | const oneHourAgo = now - 1 * 60 * 60 * 1000; 10 | return timestamp >= oneHourAgo && timestamp <= now; 11 | }; 12 | 13 | const clipPayloadSchema = z 14 | .object({ 15 | playbackId: z.string(), 16 | startTime: z.number().refine(isValidUnixTimestamp, { 17 | message: 18 | "Start time must be a valid Unix timestamp in milliseconds and within the past hour.", 19 | }), 20 | endTime: z.number().refine(isValidUnixTimestamp, { 21 | message: 22 | "End time must be a valid Unix timestamp in milliseconds and within the past hour.", 23 | }), 24 | }) 25 | .refine( 26 | (data) => { 27 | return ( 28 | data.endTime > data.startTime && data.endTime <= data.startTime + 60000 29 | ); 30 | }, 31 | { 32 | message: "Clip cannot be longer than 60 seconds.", 33 | }, 34 | ); 35 | 36 | export const createClip = async (opts: ClipPayload) => { 37 | try { 38 | const clipPayloadParsed = await clipPayloadSchema.safeParseAsync(opts); 39 | 40 | if (!clipPayloadParsed.success) { 41 | console.error(clipPayloadParsed.error); 42 | 43 | return { success: false, error: "PARAMS_ERROR" } as const; 44 | } 45 | 46 | const result = await createStreamClip(opts); 47 | 48 | if (!result.data?.asset?.playbackId) { 49 | return { success: false, error: "PLAYBACK_ID_MISSING" } as const; 50 | } 51 | 52 | return { 53 | success: true, 54 | playbackId: result.data?.asset?.playbackId, 55 | } as const; 56 | } catch (e) { 57 | console.error(e); 58 | 59 | return { success: false, error: "CLIP_ERROR" } as const; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /examples/next/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /examples/next/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: ["./src/app/**/*.{js,ts,jsx,tsx,mdx}"], 5 | plugins: [require("tailwindcss-animate")], 6 | }; 7 | export default config; 8 | -------------------------------------------------------------------------------- /examples/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /examples/with-pubnub/.env.example: -------------------------------------------------------------------------------- 1 | STUDIO_API_KEY= 2 | NEXT_PUBLIC_PUBLISH_KEY= 3 | NEXT_PUBLIC_SUBSCRIBE_KEY= 4 | -------------------------------------------------------------------------------- /examples/with-pubnub/next.config.js: -------------------------------------------------------------------------------- 1 | const { NormalModuleReplacementPlugin } = require("webpack"); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | module.exports = { 5 | reactStrictMode: true, 6 | webpack: (config, { isServer }) => { 7 | if (!isServer) { 8 | config.resolve.fallback.fs = false; 9 | config.resolve.fallback.dns = false; 10 | config.resolve.fallback.net = false; 11 | } 12 | config.plugins.push( 13 | new NormalModuleReplacementPlugin( 14 | /^hexoid$/, 15 | require.resolve("hexoid/dist/index.js"), 16 | ), 17 | ); 18 | return config; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /examples/with-pubnub/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-with-pubnub", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "clean": "rimraf .turbo node_modules .next", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 14 | "@fortawesome/react-fontawesome": "^0.2.2", 15 | "@livepeer/react": "workspace:*", 16 | "@pubnub/chat": "^0.8.1", 17 | "@radix-ui/react-popover": "^1.0.7", 18 | "@types/react-modal": "^3.16.3", 19 | "axios": "^1.7.4", 20 | "clsx": "^2.1.1", 21 | "hexoid": "^1.0.0", 22 | "livepeer": "^3.3.0", 23 | "lucide-react": "^0.379.0", 24 | "next": "14.2.5", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1", 27 | "react-loader-spinner": "^6.1.6", 28 | "react-modal": "^3.16.1", 29 | "sonner": "^1.4.41", 30 | "tailwind-merge": "^2.5.2", 31 | "tailwindcss-animate": "^1.0.7", 32 | "zod": "^3.23.8" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^22.3.0", 36 | "@types/react": "^18.3.2", 37 | "@types/react-dom": "^18.3.0", 38 | "autoprefixer": "^10.4.20", 39 | "postcss": "^8.4.41", 40 | "tailwindcss": "^3.4.10", 41 | "typescript": "^5.5.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/with-pubnub/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/with-pubnub/public/banned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/examples/with-pubnub/public/banned.png -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import type { Stream } from "livepeer/models/components"; 4 | import { revalidatePath } from "next/cache"; 5 | import { cookies } from "next/headers"; 6 | 7 | export const createLivestream = async () => { 8 | try { 9 | const response: Stream = await fetch("https://livepeer.studio/api/stream", { 10 | method: "POST", 11 | headers: { 12 | Authorization: `Bearer ${process.env.STUDIO_API_KEY ?? "none"}`, 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | name: "Pubnub <> Livepeer", 17 | }), 18 | }).then((response) => response.json()); 19 | 20 | if (response.streamKey && response.playbackId) { 21 | cookies().set("stream-key", response.streamKey); 22 | cookies().set("playback-id", response.playbackId); 23 | } else { 24 | return { 25 | success: false, 26 | error: "No stream key created.", 27 | } as const; 28 | } 29 | 30 | revalidatePath("/"); 31 | 32 | return { 33 | success: true, 34 | } as const; 35 | } catch (e) { 36 | console.error(e); 37 | return { 38 | success: false, 39 | error: "Could not create livestream.", 40 | } as const; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/create-livestream-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Videotape } from "lucide-react"; 5 | import { toast } from "sonner"; 6 | import { createLivestream } from "./actions"; 7 | 8 | export function CreateLivestreamButton({ className }: { className?: string }) { 9 | return ( 10 | <form 11 | action={async () => { 12 | const result = await createLivestream(); 13 | 14 | if (!result.success) { 15 | toast.error(result.error); 16 | } else { 17 | toast("Livestream created!"); 18 | } 19 | }} 20 | className={cn("flex flex-col", className)} 21 | > 22 | <button 23 | type="submit" 24 | className="items-center cursor-pointer rounded-md px-3 py-1 outline outline-1 outline-white/30 hover:outline-white/40 justify-center md:justify-end gap-2 flex-1 flex text-lg text-white/90 transition-opacity" 25 | > 26 | <span>Create a livestream</span> 27 | <Videotape strokeWidth={1} className="w-5 h-5" /> 28 | </button> 29 | </form> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/examples/with-pubnub/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { Toaster } from "sonner"; 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Livepeer w/ App Router", 11 | description: "Livepeer UI Kit with the Next.JS App Router", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | <html lang="en"> 21 | <body 22 | className={cn( 23 | inter.className, 24 | "dark-theme text-white bg-black h-screen", 25 | )} 26 | > 27 | {children} 28 | <Toaster theme="dark" /> 29 | </body> 30 | </html> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/view/[playbackId]/PlayerWithChat.tsx: -------------------------------------------------------------------------------- 1 | import type { Src } from "@livepeer/react"; 2 | import { PlayerWithControls } from "../../../components/player/Player"; 3 | 4 | export default function PlayerWithChat({ src }: { src: Src[] }) { 5 | return <PlayerWithControls src={src} />; 6 | } 7 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/view/[playbackId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chat/Chat"; 2 | import { ChatContextProvider } from "@/components/chat/context/ChatContext"; 3 | import type { ReactNode } from "react"; 4 | 5 | export default function PlayerLayout({ 6 | children, 7 | params, 8 | }: { children: ReactNode; params: { playbackId: string } }) { 9 | return ( 10 | <main className="grid grid-cols-8 relative min-h-screen bg-black h-full"> 11 | <div className="col-span-6 flex flex-col items-center gap-8 py-12 md:py-8 p-4"> 12 | <div className="flex gap-2 max-w-lg text-center flex-col"> 13 | <span className="text-2xl font-semibold">{"PubNub <> Livepeer"}</span> 14 | <span className="text-sm text-white/90"> 15 | Welcome to your viewer chat experience, using PubNub and Livepeer to 16 | deliver real-time streaming and interactivity with only a few lines 17 | of code. 18 | </span> 19 | </div> 20 | 21 | <span className="h-px w-full max-w-md bg-gradient-to-r from-white/5 via-white/60 to-white/5" /> 22 | 23 | {children} 24 | </div> 25 | <div className="col-span-8 md:col-span-2 h-full"> 26 | <ChatContextProvider> 27 | <Chat playbackId={params.playbackId} /> 28 | </ChatContextProvider> 29 | </div> 30 | </main> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/view/[playbackId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerLoading } from "../../../components/player/Player"; 2 | 3 | export default async function Loading() { 4 | return <PlayerLoading />; 5 | } 6 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/app/view/[playbackId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPlaybackInfo } from "@/lib/livepeer"; 2 | import { getSrc } from "@livepeer/react/external"; 3 | import { PlayerLoading } from "../../../components/player/Player"; 4 | import PlayerWithChat from "./PlayerWithChat"; 5 | 6 | export default async function PlayerPage({ 7 | params, 8 | }: { params: { playbackId: string } }) { 9 | const inputSource = await getPlaybackInfo(params.playbackId); 10 | 11 | const src = getSrc(inputSource); 12 | 13 | return src ? ( 14 | <PlayerWithChat src={src} /> 15 | ) : ( 16 | <PlayerLoading> 17 | <div className="absolute flex flex-col inset-0 justify-center items-center"> 18 | <span className="text-sm text-white/80">Video is not available.</span> 19 | <span className="text-sm text-white/80"> 20 | Please try refreshing the page in a few seconds. 21 | </span> 22 | </div> 23 | </PlayerLoading> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/chat/api/moderation.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = 4 | "https://devrel-demos-access-manager.netlify.app/.netlify/functions/api"; // Adjust this base URL to your backend server's URL 5 | 6 | export const deleteMessageAPI = async ( 7 | messageID: string, 8 | channelID: string, 9 | ) => { 10 | return await axios.post(`${baseUrl}/livepeer/delete`, { 11 | messageID: messageID, 12 | channelID: channelID, 13 | }); 14 | }; 15 | 16 | export const restoreMessageAPI = async ( 17 | messageID: string, 18 | channelID: string, 19 | ) => { 20 | return await axios.post(`${baseUrl}/livepeer/restore`, { 21 | messageID: messageID, 22 | channelID: channelID, 23 | }); 24 | }; 25 | 26 | export const setRestrictionsAPI = async ( 27 | channelID: string, 28 | userID: string, 29 | ban: boolean, 30 | mute: boolean, 31 | ) => { 32 | return await axios.post(`${baseUrl}/livepeer/setrestrictions`, { 33 | channelID: channelID, 34 | userID: userID, 35 | ban: ban, 36 | mute: mute, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/chat/components/cards/flagged-user-card.tsx: -------------------------------------------------------------------------------- 1 | import { faBan, faFlag, faVolumeMute } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import type React from "react"; 4 | 5 | interface FlaggedUserCardProps { 6 | userId: string; 7 | name: string; 8 | ban: boolean; 9 | mute: boolean; 10 | flagCount: number; 11 | } 12 | 13 | const FlaggedUserCard: React.FC<FlaggedUserCardProps> = ({ 14 | userId, 15 | name, 16 | ban, 17 | mute, 18 | flagCount, 19 | }) => { 20 | return ( 21 | <div 22 | className="bg-pubnub-dark shadow overflow-hidden rounded-lg p-4 mb-2 flex justify-between items-center" 23 | key={userId} 24 | > 25 | <p className="text-md text-pubnub-white font-medium text-gray-900 overflow-hidden whitespace-nowrap overflow-ellipsis"> 26 | {name} 27 | </p> 28 | <div className="flex items-center min-w-[100px] justify-end"> 29 | {ban && ( 30 | <div className="tooltip" data-tip="Banned"> 31 | <FontAwesomeIcon icon={faBan} className="text-pubnub-red ml-2" /> 32 | </div> 33 | )} 34 | {mute && ( 35 | <div className="tooltip" data-tip="Muted"> 36 | <FontAwesomeIcon 37 | icon={faVolumeMute} 38 | className="text-pubnub-light-grey ml-2" 39 | /> 40 | </div> 41 | )} 42 | {flagCount > 0 && ( 43 | <div className="tooltip" data-tip={`Flagged ${flagCount} times`}> 44 | <FontAwesomeIcon 45 | icon={faFlag} 46 | className="text-pubnub-yellow ml-2" 47 | /> 48 | <span className="text-pubnub-white ml-1">{flagCount}</span> 49 | </div> 50 | )} 51 | </div> 52 | </div> 53 | ); 54 | }; 55 | 56 | export default FlaggedUserCard; 57 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/chat/components/cards/restricted-user-card.tsx: -------------------------------------------------------------------------------- 1 | import { faBan, faUser, faVolumeMute } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import type React from "react"; 4 | 5 | interface UserCardProps { 6 | userId: string; 7 | name: string; 8 | ban: boolean; 9 | mute: boolean; 10 | } 11 | 12 | const RestrictedUserCard: React.FC<UserCardProps> = ({ 13 | userId, 14 | name, 15 | ban, 16 | mute, 17 | }) => { 18 | return ( 19 | <div 20 | className="bg-pubnub-dark shadow overflow-hidden rounded-lg p-4 mb-2 flex justify-between items-center" 21 | key={userId} 22 | > 23 | <p className="text-md text-pubnub-white font-medium text-gray-900 overflow-hidden whitespace-nowrap overflow-ellipsis"> 24 | {name} 25 | </p> 26 | <div className="flex items-center min-w-[100px] justify-end"> 27 | {ban && ( 28 | <div className="tooltip" data-tip="Banned"> 29 | <FontAwesomeIcon icon={faBan} className="text-pubnub-red ml-2" /> 30 | </div> 31 | )} 32 | {mute && ( 33 | <div className="tooltip" data-tip="Muted"> 34 | <FontAwesomeIcon 35 | icon={faVolumeMute} 36 | className="text-pubnub-light-grey ml-2" 37 | /> 38 | </div> 39 | )} 40 | </div> 41 | </div> 42 | ); 43 | }; 44 | 45 | export default RestrictedUserCard; 46 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/chat/components/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { type ReactNode, useState } from "react"; 3 | 4 | // Define props if needed 5 | interface DropdownProps { 6 | title: string; // For customization, 7 | children: ReactNode; 8 | } 9 | 10 | const Dropdown: React.FC<DropdownProps> = ({ title, children }) => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | 13 | // Toggle the visibility of the dropdown content 14 | const toggleDropdown = () => setIsOpen(!isOpen); 15 | 16 | return ( 17 | <div className="max-w-md mx-auto mt-5 border border-gray-300 shadow-lg rounded-md overflow-hidden"> 18 | <div 19 | className="flex justify-between items-center p-4 cursor-pointer" 20 | onClick={toggleDropdown} 21 | onKeyDown={(event) => { 22 | // Check if the key pressed is 'Enter' or 'Space' 23 | if (event.key === "Enter" || event.key === " ") { 24 | toggleDropdown(); 25 | } 26 | }} 27 | tabIndex={0} 28 | // biome-ignore lint/a11y/useSemanticElements: todo: fix this 29 | role="button" 30 | aria-pressed="false" 31 | > 32 | <h2 className="text-lg leading-6 font-medium text-pubnub-white"> 33 | {title} 34 | </h2> 35 | <svg 36 | className={`w-4 h-4 text-gray-400 transition-transform duration-300 ${ 37 | isOpen ? "transform rotate-90" : "" 38 | }`} 39 | fill="none" 40 | stroke="currentColor" 41 | viewBox="0 0 24 24" 42 | xmlns="http://www.w3.org/2000/svg" 43 | > 44 | <title>Toggle 45 | 51 | 52 | 53 | {isOpen && ( 54 |
55 | {/* Make this div scrollable */} 56 |
{children}
57 |
58 | )} 59 | 60 | ); 61 | }; 62 | 63 | export default Dropdown; 64 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/player/Clip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | 5 | import * as Player from "@livepeer/react/player"; 6 | 7 | import { ClipIcon, LoadingIcon } from "@livepeer/react/assets"; 8 | import type { ClipPayload } from "livepeer/models/components"; 9 | import { useCallback, useTransition } from "react"; 10 | import { createClip } from "./actions"; 11 | 12 | export function Clip({ className }: { className?: string }) { 13 | const [isPending, startTransition] = useTransition(); 14 | 15 | const createClipComposed = useCallback(async (opts: ClipPayload) => { 16 | startTransition(async () => { 17 | const result = await createClip(opts); 18 | 19 | if (result.success) { 20 | toast.success("Clip created!"); 21 | } else { 22 | toast.error( 23 | "Failed to create a clip. Please try again in a few seconds.", 24 | ); 25 | } 26 | }); 27 | }, []); 28 | 29 | return ( 30 | 31 | 36 | {isPending ? ( 37 | 38 | ) : ( 39 | 40 | )} 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/components/player/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createStreamClip } from "@/lib/livepeer"; 4 | import type { ClipPayload } from "livepeer/models/components"; 5 | import z from "zod"; 6 | 7 | const isValidUnixTimestamp = (timestamp: number) => { 8 | const now = Date.now(); 9 | const oneHourAgo = now - 1 * 60 * 60 * 1000; 10 | return timestamp >= oneHourAgo && timestamp <= now; 11 | }; 12 | 13 | const clipPayloadSchema = z 14 | .object({ 15 | playbackId: z.string(), 16 | startTime: z.number().refine(isValidUnixTimestamp, { 17 | message: 18 | "Start time must be a valid Unix timestamp in milliseconds and within the past hour.", 19 | }), 20 | endTime: z.number().refine(isValidUnixTimestamp, { 21 | message: 22 | "End time must be a valid Unix timestamp in milliseconds and within the past hour.", 23 | }), 24 | }) 25 | .refine( 26 | (data) => { 27 | return ( 28 | data.endTime > data.startTime && data.endTime <= data.startTime + 60000 29 | ); 30 | }, 31 | { 32 | message: "Clip cannot be longer than 60 seconds.", 33 | }, 34 | ); 35 | 36 | export const createClip = async (opts: ClipPayload) => { 37 | try { 38 | const clipPayloadParsed = await clipPayloadSchema.safeParseAsync(opts); 39 | 40 | if (!clipPayloadParsed.success) { 41 | console.error(clipPayloadParsed.error); 42 | 43 | return { success: false, error: "PARAMS_ERROR" } as const; 44 | } 45 | 46 | const result = await createStreamClip(opts); 47 | 48 | if (!result.data?.asset?.playbackId) { 49 | return { success: false, error: "PLAYBACK_ID_MISSING" } as const; 50 | } 51 | 52 | return { 53 | success: true, 54 | playbackId: result.data?.asset?.playbackId, 55 | } as const; 56 | } catch (e) { 57 | console.error(e); 58 | 59 | return { success: false, error: "CLIP_ERROR" } as const; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/lib/livepeer.ts: -------------------------------------------------------------------------------- 1 | import { Livepeer } from "livepeer"; 2 | import type { ClipPayload, NewStreamPayload } from "livepeer/models/components"; 3 | 4 | import { unstable_cache } from "next/cache"; 5 | import { cache } from "react"; 6 | 7 | const livepeer = new Livepeer({ 8 | apiKey: process.env.STUDIO_API_KEY ?? "", 9 | serverURL: process.env.STUDIO_BASE_URL, 10 | }); 11 | 12 | const getPlaybackInfoUncached = cache(async (playbackId: string) => { 13 | try { 14 | const playbackInfo = await livepeer.playback.get(playbackId); 15 | 16 | if (!playbackInfo.playbackInfo) { 17 | console.error("Error fetching playback info", playbackInfo); 18 | 19 | return null; 20 | } 21 | 22 | return playbackInfo.playbackInfo; 23 | } catch (e) { 24 | console.error(e); 25 | return null; 26 | } 27 | }); 28 | 29 | export const getPlaybackInfo = unstable_cache( 30 | async (playbackId: string) => getPlaybackInfoUncached(playbackId), 31 | ["get-playback-info"], 32 | { 33 | revalidate: 120, 34 | }, 35 | ); 36 | 37 | export const createStreamClip = async (opts: ClipPayload) => { 38 | const result = await livepeer.stream.createClip(opts); 39 | 40 | return result; 41 | }; 42 | 43 | export const createStream = async (opts: NewStreamPayload) => { 44 | const result = await livepeer.stream.create(opts); 45 | 46 | return result; 47 | }; 48 | -------------------------------------------------------------------------------- /examples/with-pubnub/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /examples/with-pubnub/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 5 | plugins: [require("tailwindcss-animate")], 6 | theme: { 7 | extend: { 8 | colors: { 9 | "pubnub-dark": "#161C2D", 10 | "pubnub-red": "#EF3A43", 11 | "pubnub-faded-red": "#FDECED", 12 | "pubnub-dark-grey": "#475569", 13 | "pubnub-light-grey": "#94A3B7", 14 | "pubnub-yellow": "#FBBF24", 15 | "pubnub-white": "#F8FAFC", 16 | }, 17 | }, 18 | }, 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /examples/with-pubnub/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /generate-version.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { 3 | name as coreName, 4 | version as coreVersion, 5 | } from "./packages/core/package.json"; 6 | import { 7 | name as reactName, 8 | version as reactVersion, 9 | } from "./packages/react/package.json"; 10 | 11 | fs.writeFileSync( 12 | "./packages/core/src/version.ts", 13 | `const core = "${coreName}@${coreVersion}"; 14 | const react = "${reactName}@${reactVersion}"; 15 | 16 | export const version = { 17 | core, 18 | react, 19 | } as const; 20 | `, 21 | ); 22 | -------------------------------------------------------------------------------- /packages/core-react/README.md: -------------------------------------------------------------------------------- 1 | # @livepeer/core-react 2 | 3 | ## Documentation 4 | 5 | The `@livepeer/core-react` package is used as a dependency in `@livepeer/react` and `@livepeer/react-native` - it should not be installed directly. For full documentation and examples, visit [docs.livepeer.org](https://docs.livepeer.org). 6 | 7 | ## Community 8 | 9 | Check out the following places for more livepeer-related content: 10 | 11 | - Join the [discussions on GitHub](https://github.com/livepeer/ui-kit/discussions) 12 | - Follow [@livepeer](https://twitter.com/livepeer) on Twitter 13 | - Jump into our [Discord](https://discord.gg/livepeer) 14 | -------------------------------------------------------------------------------- /packages/core-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@livepeer/core-react", 3 | "description": "Internal library used for livepeer react primitives.", 4 | "license": "MIT", 5 | "version": "3.2.10", 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/livepeer/ui-kit.git", 10 | "directory": "packages/core-react" 11 | }, 12 | "homepage": "https://docs.livepeer.org", 13 | "main": "./dist/index.cjs", 14 | "module": "./dist/index.js", 15 | "types": "./dist/index.d.ts", 16 | "files": [ 17 | "dist" 18 | ], 19 | "exports": { 20 | "./package.json": "./package.json", 21 | ".": { 22 | "types": "./dist/index.d.ts", 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.cjs" 25 | }, 26 | "./crypto": { 27 | "types": "./dist/crypto/index.d.ts", 28 | "import": "./dist/crypto/index.js", 29 | "require": "./dist/crypto/index.cjs" 30 | } 31 | }, 32 | "typesVersions": { 33 | "*": { 34 | "crypto": [ 35 | "./dist/crypto/index.d.ts" 36 | ], 37 | "*": [ 38 | "./dist/index.d.ts" 39 | ] 40 | } 41 | }, 42 | "scripts": { 43 | "build": "tsup", 44 | "clean": "rimraf .turbo node_modules dist", 45 | "dev": "tsup --watch", 46 | "lint": "tsc --noEmit" 47 | }, 48 | "peerDependencies": { 49 | "react": ">=17.0.0" 50 | }, 51 | "peerDependenciesMeta": {}, 52 | "dependencies": { 53 | "@livepeer/core": "workspace:*", 54 | "zustand": "^4.5.5" 55 | }, 56 | "devDependencies": { 57 | "@testing-library/react": "^16.0.0", 58 | "@testing-library/react-hooks": "^8.0.1", 59 | "@types/react": "^18.3.2", 60 | "@types/react-dom": "^18.3.0", 61 | "react": "^18.3.1", 62 | "react-dom": "^18.3.1" 63 | }, 64 | "keywords": [ 65 | "livepeer", 66 | "video", 67 | "streaming", 68 | "livestream" 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /packages/core-react/src/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./crypto"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "importPKCS8", 9 | "signAccessJwt", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/core-react/src/crypto.ts: -------------------------------------------------------------------------------- 1 | export { 2 | importPKCS8, 3 | signAccessJwt, 4 | type SignAccessJwtOptions, 5 | } from "@livepeer/core/crypto"; 6 | -------------------------------------------------------------------------------- /packages/core-react/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "ACCESS_CONTROL_ERROR_MESSAGE", 9 | "BFRAMES_ERROR_MESSAGE", 10 | "NOT_ACCEPTABLE_ERROR_MESSAGE", 11 | "PERMISSIONS_ERROR_MESSAGE", 12 | "STREAM_OFFLINE_ERROR_MESSAGE", 13 | "STREAM_OPEN_ERROR_MESSAGE", 14 | "b64Decode", 15 | "b64Encode", 16 | "b64UrlDecode", 17 | "b64UrlEncode", 18 | "createControllerStore", 19 | "createStorage", 20 | "deepMerge", 21 | "getMediaSourceType", 22 | "isAccessControlError", 23 | "isBframesError", 24 | "isNotAcceptableError", 25 | "isPermissionsError", 26 | "isStreamOfflineError", 27 | "noopStorage", 28 | "omit", 29 | "pick", 30 | "version", 31 | ] 32 | `); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/core-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Address, 3 | Hash, 4 | } from "@livepeer/core"; 5 | export { 6 | ACCESS_CONTROL_ERROR_MESSAGE, 7 | BFRAMES_ERROR_MESSAGE, 8 | NOT_ACCEPTABLE_ERROR_MESSAGE, 9 | PERMISSIONS_ERROR_MESSAGE, 10 | STREAM_OFFLINE_ERROR_MESSAGE, 11 | STREAM_OPEN_ERROR_MESSAGE, 12 | isAccessControlError, 13 | isBframesError, 14 | isNotAcceptableError, 15 | isPermissionsError, 16 | isStreamOfflineError, 17 | } from "@livepeer/core/errors"; 18 | export { 19 | createControllerStore, 20 | getMediaSourceType, 21 | } from "@livepeer/core/media"; 22 | export type { 23 | AccessControlParams, 24 | AriaText, 25 | AudioSrc, 26 | AudioTrackSelector, 27 | Base64Src, 28 | ClipLength, 29 | ClipParams, 30 | ControlsState, 31 | DeviceInformation, 32 | ElementSize, 33 | HlsSrc, 34 | InitialProps, 35 | LegacyMediaMetrics, 36 | LegacyMetricsStatus, 37 | LegacyPlaybackMonitor, 38 | MediaControllerState, 39 | MediaControllerStore, 40 | MediaSizing, 41 | Metadata, 42 | ObjectFit, 43 | PlaybackError, 44 | PlaybackEvent, 45 | PlaybackRate, 46 | SessionData, 47 | SingleAudioTrackSelector, 48 | SingleTrackSelector, 49 | SingleVideoTrackSelector, 50 | Src, 51 | VideoQuality, 52 | VideoSrc, 53 | VideoTrackSelector, 54 | WebRTCSrc, 55 | } from "@livepeer/core/media"; 56 | export { 57 | createStorage, 58 | noopStorage, 59 | } from "@livepeer/core/storage"; 60 | export type { ClientStorage } from "@livepeer/core/storage"; 61 | export { 62 | b64Decode, 63 | b64Encode, 64 | b64UrlDecode, 65 | b64UrlEncode, 66 | deepMerge, 67 | omit, 68 | pick, 69 | } from "@livepeer/core/utils"; 70 | export { version } from "@livepeer/core/version"; 71 | -------------------------------------------------------------------------------- /packages/core-react/test/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Queries, 3 | type RenderOptions, 4 | render as defaultRender, 5 | type queries, 6 | } from "@testing-library/react"; 7 | 8 | import type * as React from "react"; 9 | 10 | export const render = < 11 | Q extends Queries = typeof queries, 12 | Container extends Element | DocumentFragment = HTMLElement, 13 | BaseElement extends Element | DocumentFragment = Container, 14 | >( 15 | ui: React.ReactElement, 16 | options?: RenderOptions, 17 | ) => defaultRender(ui, { ...options }); 18 | 19 | export { act, cleanup, fireEvent, screen } from "@testing-library/react"; 20 | export { getSampleVideo } from "../../core/test"; 21 | -------------------------------------------------------------------------------- /packages/core-react/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | // make dates stable across runs 4 | Date.now = vi.fn(() => new Date(Date.UTC(2022, 1, 1)).valueOf()); 5 | 6 | type ReactVersion = "17" | "18"; 7 | const reactVersion: ReactVersion = 8 | process.env.REACT_VERSION || "18"; 9 | 10 | // set up imports for React 17 11 | vi.mock("@testing-library/react-hooks", async () => { 12 | const packages = { 13 | "18": "@testing-library/react", 14 | "17": "@testing-library/react-hooks", 15 | }; 16 | 17 | return await vi.importActual(packages[reactVersion]); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/core-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "bundler", 5 | "esModuleInterop": true, 6 | "target": "ESNext", 7 | "lib": ["es2015", "dom"], 8 | "strict": true, 9 | "strictNullChecks": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/core-react/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | /** @type {import('tsup').Options} */ 4 | const options = { 5 | splitting: false, 6 | clean: true, 7 | sourcemap: true, 8 | dts: true, 9 | format: ["esm", "cjs"], 10 | }; 11 | 12 | const entrypoints = ["crypto"]; 13 | 14 | export default defineConfig([ 15 | { 16 | ...options, 17 | entry: { 18 | index: "src/index.ts", 19 | }, 20 | outDir: "dist", 21 | }, 22 | ...entrypoints.map((entrypoint) => ({ 23 | ...options, 24 | entry: { 25 | index: `src/${entrypoint}.ts`, 26 | }, 27 | outDir: `dist/${entrypoint}`, 28 | })), 29 | ]); 30 | -------------------------------------------------------------------------------- /packages/core-web/README.md: -------------------------------------------------------------------------------- 1 | # @livepeer/core-web 2 | 3 | ## Documentation 4 | 5 | The `@livepeer/core-web` package contains vanilla JS data fetching and Livepeer provider/protocol interactions for the livepeer ecosystem. For full documentation and examples, visit [docs.livepeer.org](https://docs.livepeer.org). 6 | 7 | ## Installation 8 | 9 | Install `@livepeer/core-web` and its peer dependencies. 10 | 11 | ```bash 12 | npm install @livepeer/core-web 13 | ``` 14 | 15 | ## Community 16 | 17 | Check out the following places for more livepeer-related content: 18 | 19 | - Join the [discussions on GitHub](https://github.com/livepeer/ui-kit/discussions) 20 | - Follow [@livepeer](https://twitter.com/livepeer) on Twitter 21 | - Jump into our [Discord](https://discord.gg/livepeer) 22 | -------------------------------------------------------------------------------- /packages/core-web/src/broadcast.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./broadcast"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "addBroadcastEventListeners", 9 | "createBroadcastStore", 10 | "createSilentAudioTrack", 11 | "getBroadcastDeviceInfo", 12 | ] 13 | `); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/core-web/src/browser.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./browser"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "addEventListeners", 9 | "addMediaMetrics", 10 | "canPlayMediaNatively", 11 | "getDeviceInfo", 12 | ] 13 | `); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/core-web/src/browser.ts: -------------------------------------------------------------------------------- 1 | export { 2 | addEventListeners, 3 | getDeviceInfo, 4 | type HlsConfig, 5 | } from "./media/controls"; 6 | export { addMediaMetrics } from "./media/metrics"; 7 | export { canPlayMediaNatively } from "./media/utils"; 8 | -------------------------------------------------------------------------------- /packages/core-web/src/external.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./external"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "getIngest", 9 | "getSrc", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/core-web/src/external.ts: -------------------------------------------------------------------------------- 1 | export { getIngest, getSrc } from "@livepeer/core"; 2 | export type { 3 | CloudflareStreamData, 4 | CloudflareUrlData, 5 | LivepeerAttestation, 6 | LivepeerAttestationIpfs, 7 | LivepeerAttestationStorage, 8 | LivepeerAttestations, 9 | LivepeerDomain, 10 | LivepeerMessage, 11 | LivepeerMeta, 12 | LivepeerName, 13 | LivepeerPhase, 14 | LivepeerPlaybackInfo, 15 | LivepeerPlaybackInfoType, 16 | LivepeerPlaybackPolicy, 17 | LivepeerPrimaryType, 18 | LivepeerSignatureType, 19 | LivepeerSource, 20 | LivepeerStorageStatus, 21 | LivepeerStream, 22 | LivepeerTasks, 23 | LivepeerTypeT, 24 | LivepeerVersion, 25 | } from "@livepeer/core"; 26 | -------------------------------------------------------------------------------- /packages/core-web/src/hls.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./hls"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "VIDEO_HLS_INITIALIZED_ATTRIBUTE", 9 | "createNewHls", 10 | "isHlsSupported", 11 | ] 12 | `); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/core-web/src/hls.ts: -------------------------------------------------------------------------------- 1 | export { 2 | VIDEO_HLS_INITIALIZED_ATTRIBUTE, 3 | createNewHls, 4 | isHlsSupported, 5 | type HlsError, 6 | type HlsVideoConfig, 7 | type VideoConfig, 8 | } from "./hls/hls"; 9 | -------------------------------------------------------------------------------- /packages/core-web/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "."; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "ACCESS_CONTROL_ERROR_MESSAGE", 9 | "BFRAMES_ERROR_MESSAGE", 10 | "NOT_ACCEPTABLE_ERROR_MESSAGE", 11 | "PERMISSIONS_ERROR_MESSAGE", 12 | "STREAM_OFFLINE_ERROR_MESSAGE", 13 | "STREAM_OPEN_ERROR_MESSAGE", 14 | "b64Decode", 15 | "b64Encode", 16 | "b64UrlDecode", 17 | "b64UrlEncode", 18 | "createControllerStore", 19 | "createStorage", 20 | "deepMerge", 21 | "getMediaSourceType", 22 | "isAccessControlError", 23 | "isBframesError", 24 | "isNotAcceptableError", 25 | "isPermissionsError", 26 | "isStreamOfflineError", 27 | "noopStorage", 28 | "omit", 29 | "pick", 30 | "version", 31 | ] 32 | `); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/core-web/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Address, 3 | Hash, 4 | } from "@livepeer/core"; 5 | export { 6 | ACCESS_CONTROL_ERROR_MESSAGE, 7 | BFRAMES_ERROR_MESSAGE, 8 | NOT_ACCEPTABLE_ERROR_MESSAGE, 9 | PERMISSIONS_ERROR_MESSAGE, 10 | STREAM_OFFLINE_ERROR_MESSAGE, 11 | STREAM_OPEN_ERROR_MESSAGE, 12 | isAccessControlError, 13 | isBframesError, 14 | isNotAcceptableError, 15 | isPermissionsError, 16 | isStreamOfflineError, 17 | } from "@livepeer/core/errors"; 18 | export { 19 | createControllerStore, 20 | getMediaSourceType, 21 | } from "@livepeer/core/media"; 22 | export type { 23 | AccessControlParams, 24 | AriaText, 25 | AudioSrc, 26 | AudioTrackSelector, 27 | Base64Src, 28 | ClipLength, 29 | ClipParams, 30 | ControlsState, 31 | DeviceInformation, 32 | ElementSize, 33 | HlsSrc, 34 | InitialProps, 35 | LegacyMediaMetrics, 36 | LegacyMetricsStatus, 37 | LegacyPlaybackMonitor, 38 | MediaControllerState, 39 | MediaControllerStore, 40 | MediaSizing, 41 | Metadata, 42 | ObjectFit, 43 | PlaybackError, 44 | PlaybackEvent, 45 | PlaybackRate, 46 | SessionData, 47 | SingleAudioTrackSelector, 48 | SingleTrackSelector, 49 | SingleVideoTrackSelector, 50 | Src, 51 | VideoQuality, 52 | VideoSrc, 53 | VideoTrackSelector, 54 | WebRTCSrc, 55 | } from "@livepeer/core/media"; 56 | export { 57 | createStorage, 58 | noopStorage, 59 | } from "@livepeer/core/storage"; 60 | export type { ClientStorage } from "@livepeer/core/storage"; 61 | export { 62 | b64Decode, 63 | b64Encode, 64 | b64UrlDecode, 65 | b64UrlEncode, 66 | deepMerge, 67 | omit, 68 | pick, 69 | } from "@livepeer/core/utils"; 70 | export { version } from "@livepeer/core/version"; 71 | -------------------------------------------------------------------------------- /packages/core-web/src/media.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./media"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "createControllerStore", 9 | "getMediaSourceType", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/core-web/src/media.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createControllerStore, 3 | getMediaSourceType, 4 | } from "@livepeer/core/media"; 5 | export type { 6 | AccessControlParams, 7 | AriaText, 8 | AudioSrc, 9 | AudioTrackSelector, 10 | Base64Src, 11 | ClipLength, 12 | ClipParams, 13 | ControlsState, 14 | DeviceInformation, 15 | ElementSize, 16 | HlsSrc, 17 | InitialProps, 18 | LegacyMediaMetrics, 19 | LegacyMetricsStatus, 20 | LegacyPlaybackMonitor, 21 | MediaControllerState, 22 | MediaControllerStore, 23 | MediaSizing, 24 | Metadata, 25 | ObjectFit, 26 | PlaybackError, 27 | PlaybackEvent, 28 | PlaybackRate, 29 | SessionData, 30 | SingleAudioTrackSelector, 31 | SingleTrackSelector, 32 | SingleVideoTrackSelector, 33 | Src, 34 | VideoQuality, 35 | VideoSrc, 36 | VideoTrackSelector, 37 | WebRTCSrc, 38 | } from "@livepeer/core/media"; 39 | -------------------------------------------------------------------------------- /packages/core-web/src/media/controls/device.ts: -------------------------------------------------------------------------------- 1 | import type { DeviceInformation } from "@livepeer/core/media"; 2 | 3 | import { isHlsSupported } from "../../hls/hls"; 4 | import { getRTCPeerConnectionConstructor } from "../../webrtc/shared"; 5 | import { isAndroid, isIos, isMobile } from "../utils"; 6 | import { isFullscreenSupported } from "./fullscreen"; 7 | import { isPictureInPictureSupported } from "./pictureInPicture"; 8 | 9 | export const getDeviceInfo = (version: string): DeviceInformation => ({ 10 | version, 11 | isAndroid: isAndroid(), 12 | isIos: isIos(), 13 | isMobile: isMobile(), 14 | userAgent: 15 | typeof navigator !== "undefined" 16 | ? navigator.userAgent 17 | : "Node.js or unknown", 18 | screenWidth: 19 | typeof window !== "undefined" && window?.screen 20 | ? (window?.screen?.width ?? null) 21 | : null, 22 | 23 | isFullscreenSupported: isFullscreenSupported(), 24 | isWebRTCSupported: Boolean(getRTCPeerConnectionConstructor()), 25 | isPictureInPictureSupported: isPictureInPictureSupported(), 26 | isHlsSupported: isHlsSupported(), 27 | isVolumeChangeSupported: true, 28 | }); 29 | -------------------------------------------------------------------------------- /packages/core-web/src/media/controls/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "."; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "addEventListeners", 9 | "getDeviceInfo", 10 | "isPictureInPictureSupported", 11 | ] 12 | `); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/core-web/src/media/controls/index.ts: -------------------------------------------------------------------------------- 1 | export { addEventListeners, type HlsConfig } from "./controller"; 2 | export { getDeviceInfo } from "./device"; 3 | export { isPictureInPictureSupported } from "./pictureInPicture"; 4 | -------------------------------------------------------------------------------- /packages/core-web/src/media/controls/volume.ts: -------------------------------------------------------------------------------- 1 | // if volume change is unsupported, the element will always return 1 2 | // similar to https://github.com/videojs/video.js/pull/7514/files 3 | export const isVolumeChangeSupported = (type: "audio" | "video") => { 4 | return new Promise((resolve) => { 5 | if (typeof window === "undefined") { 6 | return false; 7 | } 8 | 9 | const testElement = document.createElement(type); 10 | const newVolume = 0.342; 11 | 12 | testElement.volume = newVolume; 13 | 14 | setTimeout(() => { 15 | const isSupported = testElement.volume !== 1; 16 | 17 | testElement.remove(); 18 | 19 | resolve(isSupported); 20 | }); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/core-web/src/webrtc.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./webrtc"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "attachMediaStreamToPeerConnection", 9 | "createNewWHEP", 10 | "createNewWHIP", 11 | "getDisplayMedia", 12 | "getMediaDevices", 13 | "getUserMedia", 14 | ] 15 | `); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/core-web/src/webrtc.ts: -------------------------------------------------------------------------------- 1 | export { createNewWHEP } from "./webrtc/whep"; 2 | export { 3 | attachMediaStreamToPeerConnection, 4 | createNewWHIP, 5 | getDisplayMedia, 6 | getMediaDevices, 7 | getUserMedia, 8 | type WebRTCConnectedPayload, 9 | } from "./webrtc/whip"; 10 | -------------------------------------------------------------------------------- /packages/core-web/test/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | MockedVideoElement, 3 | MockedWebSocket, 4 | resetDateNow, 5 | waitForWebsocketOpen, 6 | } from "./mocks"; 7 | export { getSampleVideo } from "./utils"; 8 | -------------------------------------------------------------------------------- /packages/core-web/test/mocks.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | import crypto from "node:crypto"; 4 | 5 | vi.stubGlobal("crypto", { subtle: crypto.webcrypto.subtle }); 6 | 7 | // make dates stable across runs and increment each call 8 | export const resetDateNow = () => { 9 | let nowCount = 0; 10 | 11 | Date.now = vi.fn(() => 12 | new Date(Date.UTC(2022, 1, 1)).setSeconds(nowCount++).valueOf(), 13 | ); 14 | }; 15 | 16 | export const MockedWebSocket = vi.fn(() => ({ 17 | onopen: vi.fn(), 18 | onclose: vi.fn(), 19 | send: vi.fn(), 20 | })); 21 | 22 | vi.stubGlobal("WebSocket", MockedWebSocket); 23 | 24 | export const waitForWebsocketOpen = async (_websocket: WebSocket | null) => 25 | new Promise((resolve, _reject) => { 26 | resolve(); 27 | }); 28 | 29 | export class MockedVideoElement extends HTMLVideoElement { 30 | listeners: { [key: string]: EventListenerOrEventListenerObject[] } = {}; 31 | 32 | load = vi.fn(() => { 33 | return true; 34 | }); 35 | 36 | addEventListener = vi.fn( 37 | (event: string, listener: EventListenerOrEventListenerObject) => { 38 | this.listeners[event] = [...(this.listeners[event] ?? []), listener]; 39 | }, 40 | ); 41 | removeEventListener = vi.fn( 42 | (event: string, listener: EventListenerOrEventListenerObject) => { 43 | this.listeners[event] = 44 | this.listeners[event]?.filter((l) => l !== listener) ?? []; 45 | }, 46 | ); 47 | dispatchEvent = vi.fn((e: Event) => { 48 | if (this.listeners[e.type]) { 49 | for (const listener of this.listeners[e.type] ?? []) { 50 | if (typeof listener === "function") { 51 | listener?.(e); 52 | } else { 53 | listener?.handleEvent(e); 54 | } 55 | } 56 | } 57 | 58 | return true; 59 | }); 60 | 61 | getAttribute = vi.fn(() => { 62 | return "false"; 63 | }); 64 | setAttribute = vi.fn(() => { 65 | return true; 66 | }); 67 | 68 | get duration() { 69 | return 777; 70 | } 71 | } 72 | 73 | // register the custom element 74 | customElements.define("mocked-video", MockedVideoElement, { 75 | extends: "video", 76 | }); 77 | -------------------------------------------------------------------------------- /packages/core-web/test/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/packages/core-web/test/sample.mp4 -------------------------------------------------------------------------------- /packages/core-web/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | Object.defineProperty(window, "localStorage", { 4 | value: { 5 | getItem: vi.fn(() => null), 6 | removeItem: vi.fn(() => null), 7 | setItem: vi.fn(() => null), 8 | }, 9 | writable: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core-web/test/utils.ts: -------------------------------------------------------------------------------- 1 | import fs, { type ReadStream } from "node:fs"; 2 | import path from "node:path"; 3 | 4 | export function getSampleVideo(): { file: ReadStream; uploadSize: number } { 5 | const sampleFilePath = path.resolve(__dirname, "./sample.mp4"); 6 | 7 | const { size } = fs.statSync(sampleFilePath); 8 | const file = fs.createReadStream(sampleFilePath); 9 | return { file, uploadSize: size }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/core-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "bundler", 5 | "target": "ESNext", 6 | "typeRoots": ["./global-browser"], 7 | "strict": true, 8 | "strictNullChecks": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/core-web/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | /** @type {import('tsup').Options} */ 4 | const options = { 5 | splitting: false, 6 | clean: true, 7 | sourcemap: true, 8 | dts: true, 9 | format: ["esm", "cjs"], 10 | }; 11 | 12 | const entrypoints = [ 13 | "broadcast", 14 | "browser", 15 | "external", 16 | "hls", 17 | "media", 18 | "webrtc", 19 | ]; 20 | 21 | export default defineConfig([ 22 | { 23 | ...options, 24 | entry: { 25 | index: "src/index.ts", 26 | }, 27 | outDir: "dist", 28 | }, 29 | ...entrypoints.map((entrypoint) => ({ 30 | ...options, 31 | entry: { 32 | index: `src/${entrypoint}.ts`, 33 | }, 34 | outDir: `dist/${entrypoint}`, 35 | })), 36 | ]); 37 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @livepeer/core 2 | 3 | ## Documentation 4 | 5 | The `@livepeer/core` package is used as a dependency in `livepeer` and `@livepeer/core-react` - it should not be installed directly. For full documentation and examples, visit [docs.livepeer.org](https://docs.livepeer.org). 6 | 7 | ## Community 8 | 9 | Check out the following places for more livepeer-related content: 10 | 11 | - Join the [discussions on GitHub](https://github.com/livepeer/ui-kit/discussions) 12 | - Follow [@livepeer](https://twitter.com/livepeer) on Twitter 13 | - Jump into our [Discord](https://discord.gg/livepeer) 14 | -------------------------------------------------------------------------------- /packages/core/src/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./crypto"; 4 | 5 | globalThis.crypto = crypto as Crypto; 6 | globalThis.window.crypto = crypto as Crypto; 7 | 8 | it("should expose correct exports", () => { 9 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 10 | [ 11 | "importPKCS8", 12 | "signAccessJwt", 13 | ] 14 | `); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/core/src/crypto.ts: -------------------------------------------------------------------------------- 1 | export { signAccessJwt, type SignAccessJwtOptions } from "./crypto/jwt"; 2 | export { importPKCS8 } from "./crypto/pkcs8"; 3 | -------------------------------------------------------------------------------- /packages/core/src/crypto/ecdsa.ts: -------------------------------------------------------------------------------- 1 | import { getSubtleCrypto } from "./getSubtleCrypto"; 2 | 3 | export const signEcdsaSha256 = async ( 4 | privateKey: CryptoKey, 5 | data: BufferSource, 6 | ) => { 7 | const subtleCrypto = await getSubtleCrypto(); 8 | 9 | return subtleCrypto.sign( 10 | { 11 | name: "ECDSA", 12 | hash: { name: "SHA-256" }, 13 | }, 14 | privateKey, 15 | data, 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/core/src/crypto/getSubtleCrypto.ts: -------------------------------------------------------------------------------- 1 | export const getSubtleCrypto = async () => { 2 | if (typeof crypto !== "undefined" && crypto?.subtle) { 3 | return crypto.subtle; 4 | } 5 | 6 | if (typeof globalThis?.crypto !== "undefined" && globalThis?.crypto?.subtle) { 7 | return globalThis.crypto.subtle; 8 | } 9 | 10 | try { 11 | const nodeCrypto = await import("node:crypto"); 12 | return nodeCrypto.webcrypto.subtle; 13 | } catch (error) { 14 | if (typeof window !== "undefined") { 15 | if (window?.crypto?.subtle) { 16 | return window.crypto.subtle; 17 | } 18 | 19 | throw new Error( 20 | "Browser is not in a secure context (HTTPS), cannot use SubtleCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto", 21 | ); 22 | } 23 | 24 | throw new Error( 25 | `Failed to import Node.js crypto module: ${ 26 | (error as Error)?.message ?? "" 27 | }`, 28 | ); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /packages/core/src/crypto/pkcs8.ts: -------------------------------------------------------------------------------- 1 | import { b64UrlDecode } from "../utils/string"; 2 | import { getSubtleCrypto } from "./getSubtleCrypto"; 3 | 4 | export const importPKCS8 = async (pkcs8: string): Promise => { 5 | if ( 6 | typeof pkcs8 !== "string" || 7 | pkcs8.indexOf("-----BEGIN PRIVATE KEY-----") !== 0 8 | ) { 9 | throw new TypeError('"pkcs8" must be PKCS8 formatted string'); 10 | } 11 | 12 | const privateKeyContents = b64UrlDecode( 13 | pkcs8.replace(/(?:-----(?:BEGIN|END) PRIVATE KEY-----|\s)/g, ""), 14 | ); 15 | 16 | if (!privateKeyContents) { 17 | throw new TypeError("Could not base64 decode private key contents."); 18 | } 19 | 20 | const subtleCrypto = await getSubtleCrypto(); 21 | 22 | return subtleCrypto.importKey( 23 | "pkcs8", 24 | new Uint8Array(privateKeyContents?.split("").map((c) => c.charCodeAt(0))), 25 | { 26 | name: "ECDSA", 27 | namedCurve: "P-256", 28 | }, 29 | false, 30 | ["sign"], 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/core/src/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./errors"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "ACCESS_CONTROL_ERROR_MESSAGE", 9 | "BFRAMES_ERROR_MESSAGE", 10 | "NOT_ACCEPTABLE_ERROR_MESSAGE", 11 | "PERMISSIONS_ERROR_MESSAGE", 12 | "STREAM_OFFLINE_ERROR_MESSAGE", 13 | "STREAM_OPEN_ERROR_MESSAGE", 14 | "isAccessControlError", 15 | "isBframesError", 16 | "isNotAcceptableError", 17 | "isPermissionsError", 18 | "isStreamOfflineError", 19 | ] 20 | `); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/core/src/errors.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ACCESS_CONTROL_ERROR_MESSAGE, 3 | BFRAMES_ERROR_MESSAGE, 4 | NOT_ACCEPTABLE_ERROR_MESSAGE, 5 | PERMISSIONS_ERROR_MESSAGE, 6 | STREAM_OFFLINE_ERROR_MESSAGE, 7 | STREAM_OPEN_ERROR_MESSAGE, 8 | isAccessControlError, 9 | isBframesError, 10 | isNotAcceptableError, 11 | isPermissionsError, 12 | isStreamOfflineError, 13 | } from "./media/errors"; 14 | -------------------------------------------------------------------------------- /packages/core/src/external.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./external"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "getIngest", 9 | "getSrc", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/core/src/external.ts: -------------------------------------------------------------------------------- 1 | export type Address = `0x${string}`; 2 | export type Hash = `0x${string}`; 3 | 4 | export { getIngest, getSrc } from "./media/external"; 5 | export type { 6 | CloudflareStreamData, 7 | CloudflareUrlData, 8 | LivepeerAttestation, 9 | LivepeerAttestationIpfs, 10 | LivepeerAttestationStorage, 11 | LivepeerAttestations, 12 | LivepeerDomain, 13 | LivepeerMessage, 14 | LivepeerMeta, 15 | LivepeerName, 16 | LivepeerPhase, 17 | LivepeerPlaybackInfo, 18 | LivepeerPlaybackInfoType, 19 | LivepeerPlaybackPolicy, 20 | LivepeerPrimaryType, 21 | LivepeerSignatureType, 22 | LivepeerSource, 23 | LivepeerStorageStatus, 24 | LivepeerStream, 25 | LivepeerTasks, 26 | LivepeerTypeT, 27 | LivepeerVersion, 28 | } from "./media/external"; 29 | -------------------------------------------------------------------------------- /packages/core/src/media.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./media"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "addLegacyMediaMetricsToStore", 9 | "addMetricsToStore", 10 | "calculateVideoQualityDimensions", 11 | "createControllerStore", 12 | "getBoundedVolume", 13 | "getMediaSourceType", 14 | ] 15 | `); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/core/src/media.ts: -------------------------------------------------------------------------------- 1 | export { createControllerStore } from "./media/controller"; 2 | export type { 3 | AriaText, 4 | ClipLength, 5 | ClipParams, 6 | ControlsState, 7 | DeviceInformation, 8 | ElementSize, 9 | InitialProps, 10 | MediaControllerState, 11 | MediaControllerStore, 12 | MediaSizing, 13 | Metadata, 14 | ObjectFit, 15 | PlaybackError, 16 | PlaybackRate, 17 | } from "./media/controller"; 18 | export { addLegacyMediaMetricsToStore } from "./media/metrics"; 19 | export type { 20 | LegacyMediaMetrics, 21 | LegacyMetricsStatus, 22 | LegacyPlaybackMonitor, 23 | } from "./media/metrics"; 24 | export { addMetricsToStore } from "./media/metrics-new"; 25 | export type { PlaybackEvent, SessionData } from "./media/metrics-new"; 26 | export { getMediaSourceType } from "./media/src"; 27 | export type { 28 | AccessControlParams, 29 | AudioSrc, 30 | AudioTrackSelector, 31 | Base64Src, 32 | HlsSrc, 33 | SingleAudioTrackSelector, 34 | SingleTrackSelector, 35 | SingleVideoTrackSelector, 36 | Src, 37 | VideoQuality, 38 | VideoSrc, 39 | VideoTrackSelector, 40 | WebRTCSrc, 41 | } from "./media/src"; 42 | export { 43 | calculateVideoQualityDimensions, 44 | getBoundedVolume, 45 | } from "./media/utils"; 46 | -------------------------------------------------------------------------------- /packages/core/src/media/errors.ts: -------------------------------------------------------------------------------- 1 | export const STREAM_OPEN_ERROR_MESSAGE = "stream open failed"; 2 | export const STREAM_OFFLINE_ERROR_MESSAGE = "stream is offline"; 3 | export const STREAM_WAITING_FOR_DATA_ERROR_MESSAGE = 4 | "stream is waiting for data"; 5 | export const ACCESS_CONTROL_ERROR_MESSAGE = 6 | "shutting down since this session is not allowed to view this stream"; 7 | export const BFRAMES_ERROR_MESSAGE = 8 | "metadata indicates that webrtc playback contains bframes"; 9 | export const NOT_ACCEPTABLE_ERROR_MESSAGE = 10 | "response indicates unacceptable playback protocol"; 11 | export const PERMISSIONS_ERROR_MESSAGE = 12 | "user did not allow the permissions request"; 13 | 14 | export const isStreamOfflineError = (error: Error): boolean => 15 | error.message.toLowerCase().includes(STREAM_OPEN_ERROR_MESSAGE) || 16 | error.message.toLowerCase().includes(STREAM_WAITING_FOR_DATA_ERROR_MESSAGE) || 17 | error.message.toLowerCase().includes(STREAM_OFFLINE_ERROR_MESSAGE); 18 | 19 | export const isAccessControlError = (error: Error): boolean => 20 | error.message.toLowerCase().includes(ACCESS_CONTROL_ERROR_MESSAGE); 21 | 22 | export const isBframesError = (error: Error): boolean => 23 | error.message.toLowerCase().includes(BFRAMES_ERROR_MESSAGE); 24 | 25 | export const isNotAcceptableError = (error: Error): boolean => 26 | error.message.toLowerCase().includes(NOT_ACCEPTABLE_ERROR_MESSAGE); 27 | 28 | export const isPermissionsError = (error: Error): boolean => 29 | error.message.toLowerCase().includes(PERMISSIONS_ERROR_MESSAGE); 30 | -------------------------------------------------------------------------------- /packages/core/src/media/storage.ts: -------------------------------------------------------------------------------- 1 | interface BaseStorage { 2 | getItem: (name: string) => string | null | Promise; 3 | setItem: (name: string, value: string) => void | Promise; 4 | removeItem: (name: string) => void | Promise; 5 | } 6 | 7 | export type ClientStorage = { 8 | getItem: (key: string, defaultState?: T | null) => Promise; 9 | setItem: (key: string, value: T | null) => Promise; 10 | removeItem: (key: string) => Promise; 11 | }; 12 | 13 | export const noopStorage: BaseStorage = { 14 | getItem: (_key) => "", 15 | setItem: (_key, _value) => { 16 | // 17 | }, 18 | removeItem: (_key) => { 19 | // 20 | }, 21 | }; 22 | 23 | export function createStorage({ 24 | storage = noopStorage, 25 | key: prefix = "livepeer", 26 | }: { 27 | storage?: BaseStorage; 28 | key?: string; 29 | }): ClientStorage { 30 | return { 31 | getItem: async (key, defaultState = null) => { 32 | try { 33 | const value = await storage.getItem(`${prefix}.${key}`); 34 | return value ? JSON.parse(value) : defaultState; 35 | } catch (error) { 36 | console.warn(error); 37 | return defaultState; 38 | } 39 | }, 40 | setItem: async (key, value) => { 41 | if (value === null) { 42 | await storage.removeItem(`${prefix}.${key}`); 43 | } else { 44 | try { 45 | await storage.setItem(`${prefix}.${key}`, JSON.stringify(value)); 46 | } catch (err) { 47 | console.error(err); 48 | } 49 | } 50 | }, 51 | removeItem: async (key) => storage.removeItem(`${prefix}.${key}`), 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/media/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { getFormattedHoursMinutesSeconds } from "./utils"; 3 | 4 | describe("utils", () => { 5 | describe("getFormattedHoursMinutesSeconds()", () => { 6 | it("formats a value under a minute", () => { 7 | const formatted = getFormattedHoursMinutesSeconds(22); 8 | 9 | expect(formatted).toMatchInlineSnapshot('"0:22"'); 10 | }); 11 | 12 | it("formats a value over a minute", () => { 13 | const formatted = getFormattedHoursMinutesSeconds(66); 14 | 15 | expect(formatted).toMatchInlineSnapshot('"1:06"'); 16 | }); 17 | 18 | it("formats a value over 10 minutes", () => { 19 | const formatted = getFormattedHoursMinutesSeconds(660); 20 | 21 | expect(formatted).toMatchInlineSnapshot('"11:00"'); 22 | }); 23 | 24 | it("formats a value over one hour", () => { 25 | const formatted = getFormattedHoursMinutesSeconds(3601); 26 | 27 | expect(formatted).toMatchInlineSnapshot('"1:00:01"'); 28 | }); 29 | 30 | it("formats a value over 7 hours", () => { 31 | const formatted = getFormattedHoursMinutesSeconds(25201); 32 | 33 | expect(formatted).toMatchInlineSnapshot('"7:00:01"'); 34 | }); 35 | 36 | it("formats a value = 7 hours and 1 minute", () => { 37 | const formatted = getFormattedHoursMinutesSeconds(25260); 38 | 39 | expect(formatted).toMatchInlineSnapshot('"7:01:00"'); 40 | }); 41 | 42 | it("formats a value = 7 hours and 1 minute and 5 sec", () => { 43 | const formatted = getFormattedHoursMinutesSeconds(25265); 44 | 45 | expect(formatted).toMatchInlineSnapshot('"7:01:05"'); 46 | }); 47 | 48 | it("formats a null value", () => { 49 | const formatted = getFormattedHoursMinutesSeconds(null); 50 | 51 | expect(formatted).toMatchInlineSnapshot('"0:00"'); 52 | }); 53 | 54 | it("formats a NaN value", () => { 55 | const formatted = getFormattedHoursMinutesSeconds(Number.NaN); 56 | 57 | expect(formatted).toMatchInlineSnapshot('"0:00"'); 58 | }); 59 | 60 | it("formats an undefined value", () => { 61 | const formatted = getFormattedHoursMinutesSeconds(undefined); 62 | 63 | expect(formatted).toMatchInlineSnapshot('"0:00"'); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/core/src/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { createStorage } from "./storage"; 4 | 5 | describe("createStorage", () => { 6 | it("inits", () => { 7 | const storage = createStorage({ storage: window.localStorage }); 8 | expect(storage).toBeDefined(); 9 | }); 10 | 11 | it("getItem", () => { 12 | const storage = createStorage({ storage: window.localStorage }); 13 | storage.getItem("foo"); 14 | expect(window.localStorage.getItem).toHaveBeenCalledTimes(1); 15 | expect(window.localStorage.getItem).toHaveBeenCalledWith("livepeer.foo"); 16 | }); 17 | 18 | it("setItem", () => { 19 | const storage = createStorage({ storage: window.localStorage }); 20 | storage.setItem("foo", "bar"); 21 | expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); 22 | expect(window.localStorage.setItem).toHaveBeenCalledWith( 23 | "livepeer.foo", 24 | '"bar"', 25 | ); 26 | }); 27 | 28 | it("removeItem", () => { 29 | const storage = createStorage({ storage: window.localStorage }); 30 | storage.removeItem("foo"); 31 | expect(window.localStorage.removeItem).toHaveBeenCalledTimes(1); 32 | expect(window.localStorage.removeItem).toHaveBeenCalledWith("livepeer.foo"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/core/src/storage.ts: -------------------------------------------------------------------------------- 1 | export { createStorage, noopStorage } from "./media/storage"; 2 | export type { ClientStorage } from "./media/storage"; 3 | -------------------------------------------------------------------------------- /packages/core/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./utils"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "b64Decode", 9 | "b64Encode", 10 | "b64UrlDecode", 11 | "b64UrlEncode", 12 | "deepMerge", 13 | "noop", 14 | "omit", 15 | "parseArweaveTxId", 16 | "parseCid", 17 | "pick", 18 | "warn", 19 | ] 20 | `); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | export { deepMerge } from "./utils/deepMerge"; 2 | export { omit, pick } from "./utils/omick"; 3 | export { parseArweaveTxId, parseCid } from "./utils/storage"; 4 | export { 5 | b64Decode, 6 | b64Encode, 7 | b64UrlDecode, 8 | b64UrlEncode, 9 | } from "./utils/string"; 10 | export { noop } from "./utils/types"; 11 | export { warn } from "./utils/warn"; 12 | -------------------------------------------------------------------------------- /packages/core/src/utils/omick.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a new object containing only the specified keys 3 | */ 4 | export const pick = ( 5 | obj: T, 6 | ...keys: readonly K[] 7 | ): Pick => { 8 | try { 9 | const objectKeys = Object.keys(obj); 10 | 11 | return keys 12 | .filter((key) => objectKeys.includes(key as string)) 13 | .reduce( 14 | (prev, curr) => ({ 15 | // biome-ignore lint/performance/noAccumulatingSpread: 16 | ...prev, 17 | [curr]: obj[curr], 18 | }), 19 | {}, 20 | ) as Pick; 21 | } catch (e) { 22 | throw new Error("Could not pick keys for object."); 23 | } 24 | }; 25 | 26 | /** 27 | * Create a new object excluding the specified keys 28 | */ 29 | export function omit( 30 | obj: T, 31 | ...keys: readonly K[] 32 | ): Omit { 33 | try { 34 | const objectKeys = Object.keys(obj); 35 | 36 | return objectKeys 37 | .filter((objectKey) => !keys.some((key) => String(key) === objectKey)) 38 | .reduce( 39 | (prev, curr) => ({ 40 | // biome-ignore lint/performance/noAccumulatingSpread: 41 | ...prev, 42 | [curr]: obj[curr as K], 43 | }), 44 | {}, 45 | ) as Omit; 46 | } catch (e) { 47 | throw new Error("Could not omit keys for object."); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/core/src/utils/storage/index.ts: -------------------------------------------------------------------------------- 1 | export { parseArweaveTxId } from "./arweave"; 2 | export { parseCid } from "./ipfs"; 3 | -------------------------------------------------------------------------------- /packages/core/src/utils/string.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { b64Decode, b64Encode, b64UrlDecode, b64UrlEncode } from "./string"; 4 | 5 | describe("b64", () => { 6 | describe("default", () => { 7 | describe("encodes", () => { 8 | it("correctly encodes a precomputed value", () => { 9 | expect(b64Encode("somevalue")).toMatchInlineSnapshot('"c29tZXZhbHVl"'); 10 | }); 11 | }); 12 | 13 | describe("decodes", () => { 14 | it("correctly decodes a precomputed value", () => { 15 | expect(b64Decode("c29tZXZhbHVl")).toMatchInlineSnapshot('"somevalue"'); 16 | }); 17 | 18 | it("returns null on invalid value", () => { 19 | expect(b64Decode("------")).toEqual(null); 20 | }); 21 | 22 | it("returns null on invalid value", () => { 23 | expect(b64Decode("\\\\\\")).toEqual(null); 24 | }); 25 | }); 26 | }); 27 | 28 | describe("url", () => { 29 | describe("encodes", () => { 30 | it("correctly encodes a precomputed value", () => { 31 | expect(b64UrlEncode("somevalue")).toMatchInlineSnapshot( 32 | '"c29tZXZhbHVl"', 33 | ); 34 | }); 35 | }); 36 | 37 | describe("decodes", () => { 38 | it("correctly decodes a precomputed value", () => { 39 | expect(b64UrlDecode("c29tZXZhbHVl")).toMatchInlineSnapshot( 40 | '"somevalue"', 41 | ); 42 | }); 43 | 44 | it("returns null on invalid value", () => { 45 | expect(b64UrlDecode("\\\\\\")).toEqual(null); 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/core/src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const b64Encode = (input: string): string | null => { 2 | try { 3 | if (typeof window !== "undefined" && "btoa" in window) { 4 | return window?.btoa?.(input) ?? null; 5 | } 6 | return Buffer?.from(input, "binary")?.toString("base64") ?? null; 7 | } catch (e) { 8 | return null; 9 | } 10 | }; 11 | 12 | export const b64Decode = (input: string): string | null => { 13 | try { 14 | if (typeof window !== "undefined" && "atob" in window) { 15 | return window?.atob?.(input) ?? null; 16 | } 17 | return Buffer?.from(input, "base64")?.toString("binary") ?? null; 18 | } catch (e) { 19 | return null; 20 | } 21 | }; 22 | 23 | export const b64UrlEncode = (input: string): string | null => { 24 | return escapeInput(b64Encode(input)); 25 | }; 26 | 27 | export const b64UrlDecode = (input: string): string | null => { 28 | const unescaped = unescapeInput(input); 29 | if (unescaped) { 30 | return b64Decode(unescaped); 31 | } 32 | return null; 33 | }; 34 | 35 | const unescapeInput = (input: string | undefined | null) => { 36 | return input 37 | ? (input + "===".slice((input.length + 3) % 4)) 38 | .replace(/-/g, "+") 39 | .replace(/_/g, "/") 40 | : null; 41 | }; 42 | 43 | const escapeInput = (input: string | undefined | null) => { 44 | return ( 45 | input?.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") ?? null 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/core/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/suspicious/noExplicitAny: any 2 | export const noop = (..._args: any[]) => { 3 | // 4 | }; 5 | -------------------------------------------------------------------------------- /packages/core/src/utils/warn.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { warn } from "./warn"; 4 | 5 | describe("warn", () => { 6 | describe("args", () => { 7 | it("message", () => { 8 | console.warn = vi.fn(); 9 | const message = "foo bar baz"; 10 | warn(message); 11 | expect(console.warn).toBeCalledWith(message); 12 | }); 13 | 14 | it("id", () => { 15 | console.warn = vi.fn(); 16 | const message = "the quick brown fox"; 17 | warn(message); 18 | warn(message, "repeat"); 19 | expect(console.warn).toBeCalledWith(message); 20 | expect(console.warn).toBeCalledTimes(2); 21 | }); 22 | }); 23 | 24 | describe("behavior", () => { 25 | it("calls only once per message", () => { 26 | console.warn = vi.fn(); 27 | const message = "hello world"; 28 | warn(message); 29 | warn(message); 30 | expect(console.warn).toBeCalledWith(message); 31 | expect(console.warn).toBeCalledTimes(1); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/core/src/utils/warn.ts: -------------------------------------------------------------------------------- 1 | const cache = new Set(); 2 | 3 | export function warn(message: string, id?: string) { 4 | if (!cache.has(id ?? message)) { 5 | console.warn(message); 6 | cache.add(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/version.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./version"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "version", 9 | ] 10 | `); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/core/src/version.ts: -------------------------------------------------------------------------------- 1 | const core = "@livepeer/core@3.3.0"; 2 | const react = "@livepeer/react@4.3.3"; 3 | 4 | export const version = { 5 | core, 6 | react, 7 | } as const; 8 | -------------------------------------------------------------------------------- /packages/core/test/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | MockedVideoElement, 3 | MockedWebSocket, 4 | resetDateNow, 5 | waitForWebsocketOpen, 6 | } from "./mocks"; 7 | export { getSampleVideo } from "./utils"; 8 | -------------------------------------------------------------------------------- /packages/core/test/mocks.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | // make dates stable across runs and increment each call 4 | export const resetDateNow = () => { 5 | let nowCount = 0; 6 | 7 | Date.now = vi.fn(() => 8 | new Date(Date.UTC(2022, 1, 1)).setSeconds(nowCount++).valueOf(), 9 | ); 10 | }; 11 | 12 | export const MockedWebSocket = vi.fn(() => ({ 13 | onopen: vi.fn(), 14 | onclose: vi.fn(), 15 | send: vi.fn(), 16 | })); 17 | 18 | vi.stubGlobal("WebSocket", MockedWebSocket); 19 | 20 | export const waitForWebsocketOpen = async (_websocket: WebSocket | null) => 21 | new Promise((resolve, _reject) => { 22 | resolve(); 23 | }); 24 | 25 | export class MockedVideoElement extends HTMLVideoElement { 26 | listeners: { [key: string]: EventListenerOrEventListenerObject[] } = {}; 27 | 28 | load = vi.fn(() => { 29 | return true; 30 | }); 31 | 32 | addEventListener = vi.fn( 33 | (event: string, listener: EventListenerOrEventListenerObject) => { 34 | this.listeners[event] = [...(this.listeners[event] ?? []), listener]; 35 | }, 36 | ); 37 | removeEventListener = vi.fn( 38 | (event: string, listener: EventListenerOrEventListenerObject) => { 39 | this.listeners[event] = 40 | this.listeners[event]?.filter((l) => l !== listener) ?? []; 41 | }, 42 | ); 43 | dispatchEvent = vi.fn((e: Event) => { 44 | if (this.listeners[e.type]) { 45 | for (const listener of this.listeners[e.type] ?? []) { 46 | if (typeof listener === "function") { 47 | listener?.(e); 48 | } else { 49 | listener?.handleEvent(e); 50 | } 51 | } 52 | } 53 | 54 | return true; 55 | }); 56 | 57 | getAttribute = vi.fn(() => { 58 | return "false"; 59 | }); 60 | setAttribute = vi.fn(() => { 61 | return true; 62 | }); 63 | 64 | get duration() { 65 | return 777; 66 | } 67 | } 68 | 69 | // register the custom element 70 | customElements.define("mocked-video", MockedVideoElement, { 71 | extends: "video", 72 | }); 73 | -------------------------------------------------------------------------------- /packages/core/test/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livepeer/ui-kit/bec14d79e8c70b26da2d362d9290f68b4f57a9f5/packages/core/test/sample.mp4 -------------------------------------------------------------------------------- /packages/core/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | Object.defineProperty(window, "localStorage", { 4 | value: { 5 | getItem: vi.fn(() => null), 6 | removeItem: vi.fn(() => null), 7 | setItem: vi.fn(() => null), 8 | }, 9 | writable: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "bundler", 5 | "target": "ESNext", 6 | "strict": true, 7 | "strictNullChecks": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | /** @type {import('tsup').Options} */ 4 | const options = { 5 | splitting: false, 6 | clean: true, 7 | sourcemap: true, 8 | dts: true, 9 | format: ["esm", "cjs"], 10 | }; 11 | 12 | const entrypoints = [ 13 | "crypto", 14 | "errors", 15 | "media", 16 | "storage", 17 | "utils", 18 | "version", 19 | ]; 20 | 21 | export default defineConfig([ 22 | { 23 | ...options, 24 | entry: { 25 | index: "src/external.ts", 26 | }, 27 | outDir: "dist", 28 | }, 29 | ...entrypoints.map((entrypoint) => ({ 30 | ...options, 31 | entry: { 32 | index: `src/${entrypoint}.ts`, 33 | }, 34 | outDir: `dist/${entrypoint}`, 35 | })), 36 | ]); 37 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # @livepeer/react 2 | 3 | ## Documentation 4 | 5 | The `@livepeer/react` package contains React-specific components for the livepeer ecosystem. For full documentation and examples, visit [docs.livepeer.org](https://docs.livepeer.org). 6 | 7 | ## Installation 8 | 9 | Install `@livepeer/react` using a package manager. 10 | 11 | ```bash 12 | npm install @livepeer/react 13 | ``` 14 | 15 | ## Community 16 | 17 | Check out the following places for more livepeer-related content: 18 | 19 | - Join the [discussions on GitHub](https://github.com/livepeer/ui-kit/discussions) 20 | - Follow [@livepeer](https://twitter.com/livepeer) on Twitter 21 | - Jump into our [Discord](https://discord.gg/livepeer) 22 | -------------------------------------------------------------------------------- /packages/react/src/assets.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./assets"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "ClipIcon", 9 | "DisableAudioIcon", 10 | "DisableVideoIcon", 11 | "EnableAudioIcon", 12 | "EnableVideoIcon", 13 | "EnterFullscreenIcon", 14 | "ExitFullscreenIcon", 15 | "LoadingIcon", 16 | "MuteIcon", 17 | "OfflineErrorIcon", 18 | "PauseIcon", 19 | "PictureInPictureIcon", 20 | "PlayIcon", 21 | "PrivateErrorIcon", 22 | "SettingsIcon", 23 | "StartScreenshareIcon", 24 | "StopIcon", 25 | "StopScreenshareIcon", 26 | "UnmuteIcon", 27 | ] 28 | `); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/react/src/broadcast.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./broadcast"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "AudioEnabledIndicator", 9 | "AudioEnabledTrigger", 10 | "BroadcastProvider", 11 | "Container", 12 | "Controls", 13 | "EnabledIndicator", 14 | "EnabledTrigger", 15 | "ErrorIndicator", 16 | "FullscreenIndicator", 17 | "FullscreenTrigger", 18 | "LoadingIndicator", 19 | "MediaProvider", 20 | "PictureInPictureTrigger", 21 | "Portal", 22 | "Range", 23 | "Root", 24 | "ScreenshareIndicator", 25 | "ScreenshareTrigger", 26 | "SelectArrow", 27 | "SelectContent", 28 | "SelectGroup", 29 | "SelectIcon", 30 | "SelectItem", 31 | "SelectItemIndicator", 32 | "SelectItemText", 33 | "SelectLabel", 34 | "SelectPortal", 35 | "SelectRoot", 36 | "SelectScrollDownButton", 37 | "SelectScrollUpButton", 38 | "SelectSeparator", 39 | "SelectTrigger", 40 | "SelectValue", 41 | "SelectViewport", 42 | "SourceSelect", 43 | "StatusIndicator", 44 | "Thumb", 45 | "Time", 46 | "Track", 47 | "Video", 48 | "VideoEnabledIndicator", 49 | "VideoEnabledTrigger", 50 | "createBroadcastScope", 51 | "createMediaScope", 52 | "useBroadcastContext", 53 | "useMediaContext", 54 | "useStore", 55 | ] 56 | `); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/react/src/broadcast/context.tsx: -------------------------------------------------------------------------------- 1 | import type { BroadcastStore } from "@livepeer/core-web/broadcast"; 2 | import { createContextScope } from "@radix-ui/react-context"; 3 | 4 | import type { Scope } from "@radix-ui/react-context"; 5 | 6 | const MEDIA_NAME = "Broadcast"; 7 | 8 | // biome-ignore lint/complexity/noBannedTypes: allow {} 9 | type BroadcastScopedProps

= P & { __scopeBroadcast?: Scope }; 10 | const [createBroadcastContext, createBroadcastScope] = 11 | createContextScope(MEDIA_NAME); 12 | 13 | type BroadcastContextValue = { 14 | store: BroadcastStore; 15 | }; 16 | 17 | const [BroadcastProvider, useBroadcastContext] = 18 | createBroadcastContext(MEDIA_NAME); 19 | 20 | export { BroadcastProvider, createBroadcastScope, useBroadcastContext }; 21 | export type { BroadcastContextValue, BroadcastScopedProps }; 22 | -------------------------------------------------------------------------------- /packages/react/src/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./crypto"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "importPKCS8", 9 | "signAccessJwt", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/react/src/crypto.ts: -------------------------------------------------------------------------------- 1 | export { 2 | importPKCS8, 3 | signAccessJwt, 4 | type SignAccessJwtOptions, 5 | } from "@livepeer/core/crypto"; 6 | -------------------------------------------------------------------------------- /packages/react/src/external.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./external"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "getIngest", 9 | "getSrc", 10 | ] 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/react/src/external.ts: -------------------------------------------------------------------------------- 1 | export { getIngest, getSrc } from "@livepeer/core"; 2 | export type { 3 | CloudflareStreamData, 4 | CloudflareUrlData, 5 | LivepeerAttestation, 6 | LivepeerAttestationIpfs, 7 | LivepeerAttestationStorage, 8 | LivepeerAttestations, 9 | LivepeerDomain, 10 | LivepeerMessage, 11 | LivepeerMeta, 12 | LivepeerName, 13 | LivepeerPhase, 14 | LivepeerPlaybackInfo, 15 | LivepeerPlaybackInfoType, 16 | LivepeerPlaybackPolicy, 17 | LivepeerPrimaryType, 18 | LivepeerSignatureType, 19 | LivepeerSource, 20 | LivepeerStorageStatus, 21 | LivepeerStream, 22 | LivepeerTasks, 23 | LivepeerTypeT, 24 | LivepeerVersion, 25 | } from "@livepeer/core"; 26 | -------------------------------------------------------------------------------- /packages/react/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "createControllerStore", 9 | "createStorage", 10 | "getMediaSourceType", 11 | "noopStorage", 12 | "version", 13 | ] 14 | `); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | AudioDeviceId, 3 | BroadcastAriaText, 4 | BroadcastControlsState, 5 | BroadcastDeviceInformation, 6 | BroadcastState, 7 | BroadcastStatus, 8 | BroadcastStore, 9 | InitialBroadcastProps, 10 | MediaDeviceIds, 11 | MediaDeviceInfoExtended, 12 | VideoDeviceId, 13 | } from "@livepeer/core-web/broadcast"; 14 | export { 15 | createControllerStore, 16 | getMediaSourceType, 17 | } from "@livepeer/core/media"; 18 | export type { 19 | AccessControlParams, 20 | AriaText, 21 | AudioSrc, 22 | AudioTrackSelector, 23 | Base64Src, 24 | ClipLength, 25 | ClipParams, 26 | ControlsState, 27 | DeviceInformation, 28 | ElementSize, 29 | HlsSrc, 30 | InitialProps, 31 | LegacyMediaMetrics, 32 | LegacyMetricsStatus, 33 | LegacyPlaybackMonitor, 34 | MediaControllerState, 35 | MediaControllerStore, 36 | MediaSizing, 37 | Metadata, 38 | ObjectFit, 39 | PlaybackError, 40 | PlaybackEvent, 41 | PlaybackRate, 42 | SessionData, 43 | SingleAudioTrackSelector, 44 | SingleTrackSelector, 45 | SingleVideoTrackSelector, 46 | Src, 47 | VideoQuality, 48 | VideoSrc, 49 | VideoTrackSelector, 50 | WebRTCSrc, 51 | } from "@livepeer/core/media"; 52 | export { 53 | createStorage, 54 | noopStorage, 55 | } from "@livepeer/core/storage"; 56 | export type { ClientStorage } from "@livepeer/core/storage"; 57 | export { version } from "@livepeer/core/version"; 58 | -------------------------------------------------------------------------------- /packages/react/src/player.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as Exports from "./player"; 4 | 5 | it("should expose correct exports", () => { 6 | expect(Object.keys(Exports).sort()).toMatchInlineSnapshot(` 7 | [ 8 | "ClipTrigger", 9 | "Container", 10 | "Controls", 11 | "ErrorIndicator", 12 | "FullscreenIndicator", 13 | "FullscreenTrigger", 14 | "LiveIndicator", 15 | "LoadingIndicator", 16 | "MediaProvider", 17 | "MuteTrigger", 18 | "PictureInPictureTrigger", 19 | "PlayPauseTrigger", 20 | "PlayingIndicator", 21 | "Portal", 22 | "Poster", 23 | "Range", 24 | "RateSelect", 25 | "RateSelectItem", 26 | "Root", 27 | "Seek", 28 | "SeekBuffer", 29 | "SelectArrow", 30 | "SelectContent", 31 | "SelectGroup", 32 | "SelectIcon", 33 | "SelectItemIndicator", 34 | "SelectItemText", 35 | "SelectLabel", 36 | "SelectPortal", 37 | "SelectScrollDownButton", 38 | "SelectScrollUpButton", 39 | "SelectSeparator", 40 | "SelectTrigger", 41 | "SelectValue", 42 | "SelectViewport", 43 | "Thumb", 44 | "Time", 45 | "Track", 46 | "Video", 47 | "VideoQualitySelect", 48 | "VideoQualitySelectItem", 49 | "Volume", 50 | "VolumeIndicator", 51 | "createMediaScope", 52 | "useMediaContext", 53 | "useStore", 54 | ] 55 | `); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/react/src/player/LiveIndicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo } from "react"; 4 | 5 | import { Presence } from "@radix-ui/react-presence"; 6 | import { useStore } from "zustand"; 7 | import { type MediaScopedProps, useMediaContext } from "../shared/context"; 8 | import * as Radix from "../shared/primitive"; 9 | 10 | const LIVE_INDICATOR_NAME = "LiveIndicator"; 11 | 12 | type LiveIndicatorElement = React.ElementRef; 13 | 14 | interface LiveIndicatorProps 15 | extends Radix.ComponentPropsWithoutRef { 16 | /** 17 | * Used to force mounting when more control is needed. Useful when 18 | * controlling animation with React animation libraries. 19 | */ 20 | forceMount?: true; 21 | /** The matcher used to determine whether the element should be shown, given the `live` state (true for live streams, and false for assets). Defaults to `true`. */ 22 | matcher?: boolean | ((live: boolean) => boolean); 23 | } 24 | 25 | const LiveIndicator = React.forwardRef< 26 | LiveIndicatorElement, 27 | LiveIndicatorProps 28 | >((props: MediaScopedProps, forwardedRef) => { 29 | const { 30 | __scopeMedia, 31 | forceMount, 32 | matcher = true, 33 | ...liveIndicatorProps 34 | } = props; 35 | 36 | const context = useMediaContext(LIVE_INDICATOR_NAME, __scopeMedia); 37 | 38 | const live = useStore(context.store, ({ live }) => live); 39 | 40 | const isPresent = useMemo( 41 | () => (typeof matcher === "function" ? matcher(live) : matcher === live), 42 | [matcher, live], 43 | ); 44 | 45 | return ( 46 | 47 | 55 | 56 | ); 57 | }); 58 | 59 | LiveIndicator.displayName = LIVE_INDICATOR_NAME; 60 | 61 | export { LiveIndicator }; 62 | export type { LiveIndicatorProps }; 63 | -------------------------------------------------------------------------------- /packages/react/src/player/MuteTrigger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { composeEventHandlers } from "@radix-ui/primitive"; 4 | 5 | import React from "react"; 6 | 7 | import { useStore } from "zustand"; 8 | import { type MediaScopedProps, useMediaContext } from "../shared/context"; 9 | 10 | import { useShallow } from "zustand/react/shallow"; 11 | import * as Radix from "../shared/primitive"; 12 | import { noPropagate } from "../shared/utils"; 13 | 14 | const MUTE_TRIGGER_NAME = "MuteTrigger"; 15 | 16 | type MuteTriggerElement = React.ElementRef; 17 | 18 | interface MuteTriggerProps 19 | extends Radix.ComponentPropsWithoutRef {} 20 | 21 | const MuteTrigger = React.forwardRef( 22 | (props: MediaScopedProps, forwardedRef) => { 23 | const { __scopeMedia, ...playProps } = props; 24 | 25 | const context = useMediaContext(MUTE_TRIGGER_NAME, __scopeMedia); 26 | 27 | const { muted, toggleMute } = useStore( 28 | context.store, 29 | useShallow(({ __controls, __controlsFunctions }) => ({ 30 | muted: __controls.muted, 31 | toggleMute: __controlsFunctions.requestToggleMute, 32 | })), 33 | ); 34 | 35 | const title = React.useMemo( 36 | () => (muted ? "Unmute (m)" : "Mute (m)"), 37 | [muted], 38 | ); 39 | 40 | return ( 41 | 52 | ); 53 | }, 54 | ); 55 | 56 | MuteTrigger.displayName = MUTE_TRIGGER_NAME; 57 | 58 | export { MuteTrigger }; 59 | export type { MuteTriggerProps }; 60 | -------------------------------------------------------------------------------- /packages/react/src/player/Poster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { Presence } from "@radix-ui/react-presence"; 6 | import { useStore } from "zustand"; 7 | import { type MediaScopedProps, useMediaContext } from "../shared/context"; 8 | import * as Radix from "../shared/primitive"; 9 | 10 | const POSTER_NAME = "Poster"; 11 | 12 | type PosterElement = React.ElementRef; 13 | 14 | interface PosterProps 15 | extends Radix.ComponentPropsWithoutRef { 16 | /** 17 | * Used to force mounting when more control is needed. Useful when 18 | * controlling animation with React animation libraries. 19 | */ 20 | forceMount?: true; 21 | } 22 | 23 | const Poster = React.forwardRef( 24 | (props: MediaScopedProps, forwardedRef) => { 25 | const { __scopeMedia, forceMount, src, ...posterProps } = props; 26 | 27 | const context = useMediaContext(POSTER_NAME, __scopeMedia); 28 | 29 | const poster = useStore(context.store, ({ poster }) => poster); 30 | 31 | return ( 32 | 33 | 43 | ); 44 | }, 45 | ); 46 | 47 | Poster.displayName = POSTER_NAME; 48 | 49 | export { Poster }; 50 | export type { PosterProps }; 51 | -------------------------------------------------------------------------------- /packages/react/src/shared/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo } from "react"; 4 | 5 | import { Presence } from "@radix-ui/react-presence"; 6 | import { useStore } from "zustand"; 7 | import { type MediaScopedProps, useMediaContext } from "./context"; 8 | import * as Radix from "./primitive"; 9 | 10 | const LOADING_INDICATOR_NAME = "LoadingIndicator"; 11 | 12 | type LoadingIndicatorElement = React.ElementRef; 13 | 14 | interface LoadingIndicatorProps 15 | extends Radix.ComponentPropsWithoutRef { 16 | /** 17 | * Used to force mounting when more control is needed. Useful when 18 | * controlling animation with React animation libraries. 19 | */ 20 | forceMount?: true; 21 | /** The matcher used to determine whether the element should be shown, given the `loading` state. Defaults to `true`. */ 22 | matcher?: boolean | ((live: boolean) => boolean); 23 | } 24 | 25 | const LoadingIndicator = React.forwardRef< 26 | LoadingIndicatorElement, 27 | LoadingIndicatorProps 28 | >((props: MediaScopedProps, forwardedRef) => { 29 | const { 30 | __scopeMedia, 31 | forceMount, 32 | matcher = true, 33 | ...offlineErrorProps 34 | } = props; 35 | 36 | const context = useMediaContext(LOADING_INDICATOR_NAME, __scopeMedia); 37 | 38 | const loading = useStore(context.store, ({ loading }) => loading); 39 | 40 | const isPresent = useMemo( 41 | () => 42 | typeof matcher === "function" ? matcher(loading) : matcher === loading, 43 | [matcher, loading], 44 | ); 45 | 46 | return ( 47 | 48 | 56 | 57 | ); 58 | }); 59 | 60 | LoadingIndicator.displayName = LOADING_INDICATOR_NAME; 61 | 62 | export { LoadingIndicator }; 63 | export type { LoadingIndicatorProps }; 64 | -------------------------------------------------------------------------------- /packages/react/src/shared/Portal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // biome-ignore lint/style/useImportType: necessary import 4 | import React from "react"; 5 | 6 | import * as RadixPortal from "@radix-ui/react-portal"; 7 | 8 | const PORTAL_NAME = "Portal"; 9 | 10 | type PortalProps = React.ComponentPropsWithoutRef; 11 | 12 | const Portal: React.FC = (props: PortalProps) => { 13 | return ; 14 | }; 15 | 16 | Portal.displayName = PORTAL_NAME; 17 | 18 | export { Portal, type PortalProps }; 19 | -------------------------------------------------------------------------------- /packages/react/src/shared/Slider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SliderPrimitive from "@radix-ui/react-slider"; 4 | 5 | type SliderProps = SliderPrimitive.SliderProps; 6 | const Root = SliderPrimitive.Root; 7 | type TrackProps = SliderPrimitive.SliderTrackProps; 8 | const Track = SliderPrimitive.Track; 9 | type RangeProps = SliderPrimitive.SliderRangeProps; 10 | const Range = SliderPrimitive.Range; 11 | type ThumbProps = SliderPrimitive.SliderThumbProps; 12 | const Thumb = SliderPrimitive.Thumb; 13 | 14 | export { Range, Root, Thumb, Track }; 15 | export type { RangeProps, SliderProps, ThumbProps, TrackProps }; 16 | -------------------------------------------------------------------------------- /packages/react/src/shared/Time.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { useStore } from "zustand"; 6 | import { type MediaScopedProps, useMediaContext } from "./context"; 7 | 8 | import { useShallow } from "zustand/react/shallow"; 9 | import * as Radix from "./primitive"; 10 | 11 | const TIME_NAME = "Time"; 12 | 13 | type TimeElement = React.ElementRef; 14 | 15 | interface TimeProps 16 | extends Omit< 17 | Radix.ComponentPropsWithoutRef, 18 | "children" 19 | > {} 20 | 21 | const Time = React.forwardRef( 22 | (props: MediaScopedProps, forwardedRef) => { 23 | const { __scopeMedia, ...timeProps } = props; 24 | 25 | const context = useMediaContext(TIME_NAME, __scopeMedia); 26 | 27 | const { progress, duration, live, formattedTime } = useStore( 28 | context.store, 29 | useShallow(({ progress, duration, live, aria }) => ({ 30 | formattedTime: aria.time, 31 | progress, 32 | duration, 33 | live, 34 | })), 35 | ); 36 | 37 | return ( 38 | 48 | {formattedTime} 49 | 50 | ); 51 | }, 52 | ); 53 | 54 | Time.displayName = TIME_NAME; 55 | 56 | export { Time }; 57 | export type { TimeProps }; 58 | -------------------------------------------------------------------------------- /packages/react/src/shared/context.tsx: -------------------------------------------------------------------------------- 1 | import type { MediaControllerStore } from "@livepeer/core/media"; 2 | import { createContextScope } from "@radix-ui/react-context"; 3 | import { useStore as useStoreZustand } from "zustand"; 4 | 5 | import type { Scope } from "@radix-ui/react-context"; 6 | 7 | const MEDIA_NAME = "Media"; 8 | 9 | // biome-ignore lint/complexity/noBannedTypes: allow {} 10 | type MediaScopedProps

= P & { __scopeMedia?: Scope }; 11 | const [createMediaContext, createMediaScope] = createContextScope(MEDIA_NAME); 12 | 13 | type MediaContextValue = { 14 | store: MediaControllerStore; 15 | }; 16 | 17 | const [MediaProvider, useMediaContext] = 18 | createMediaContext(MEDIA_NAME); 19 | 20 | const useStore = useStoreZustand; 21 | 22 | export { MediaProvider, createMediaScope, useMediaContext, useStore }; 23 | export type { MediaContextValue, MediaScopedProps }; 24 | -------------------------------------------------------------------------------- /packages/react/src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | export const noPropagate = 2 | < 3 | E extends { 4 | stopPropagation(): void; 5 | }, 6 | >( 7 | // biome-ignore lint/suspicious/noExplicitAny: any 8 | cb: (...args: any) => any, 9 | ) => 10 | (event: E) => { 11 | event.stopPropagation(); 12 | 13 | return cb(); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/react/test/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Queries, 3 | type RenderOptions, 4 | render as defaultRender, 5 | type queries, 6 | } from "@testing-library/react"; 7 | 8 | import type * as React from "react"; 9 | 10 | export const render = < 11 | Q extends Queries = typeof queries, 12 | Container extends Element | DocumentFragment = HTMLElement, 13 | BaseElement extends Element | DocumentFragment = Container, 14 | >( 15 | ui: React.ReactElement, 16 | options?: RenderOptions, 17 | ) => defaultRender(ui, { ...options }); 18 | 19 | export { act, cleanup, fireEvent, screen } from "@testing-library/react"; 20 | export { getSampleVideo } from "../../core/test"; 21 | -------------------------------------------------------------------------------- /packages/react/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | // make dates stable across runs 4 | Date.now = vi.fn(() => new Date(Date.UTC(2022, 1, 1)).valueOf()); 5 | 6 | type ReactVersion = "17" | "18"; 7 | const reactVersion: ReactVersion = 8 | process.env.REACT_VERSION || "18"; 9 | 10 | // set up imports for React 17 11 | vi.mock("@testing-library/react-hooks", async () => { 12 | const packages = { 13 | "18": "@testing-library/react", 14 | "17": "@testing-library/react-hooks", 15 | }; 16 | 17 | return await vi.importActual(packages[reactVersion]); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "bundler", 5 | "esModuleInterop": true, 6 | "target": "ESNext", 7 | "lib": ["es2015", "dom"], 8 | "strict": true, 9 | "strictNullChecks": true 10 | }, 11 | "watchOptions": { 12 | "watchFile": "useFsEvents", 13 | "watchDirectory": "useFsEvents", 14 | "fallbackPolling": "dynamicPriority" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/react/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | /** @type {import('tsup').Options} */ 4 | const options = { 5 | splitting: false, 6 | clean: true, 7 | sourcemap: true, 8 | dts: true, 9 | format: ["esm", "cjs"], 10 | }; 11 | 12 | /** @type {import('tsup').Options} */ 13 | const reactServerOptions = { 14 | ...options, 15 | external: ["react"], 16 | }; 17 | 18 | /** @type {import('tsup').Options} */ 19 | const reactClientOptions = { 20 | ...reactServerOptions, 21 | esbuildOptions: (options) => { 22 | // Append "use client" to the top of the react entry point 23 | options.banner = { 24 | js: '"use client";', 25 | }; 26 | }, 27 | }; 28 | 29 | const entrypoints = ["crypto", "external"]; 30 | const reactServerEntrypoints = ["assets"]; 31 | const reactClientEntrypoints = ["broadcast", "player"]; 32 | 33 | export default defineConfig([ 34 | { 35 | ...options, 36 | entry: { 37 | index: "src/index.ts", 38 | }, 39 | outDir: "dist", 40 | }, 41 | ...entrypoints.map((entrypoint) => ({ 42 | ...options, 43 | entry: { 44 | index: `src/${entrypoint}.ts`, 45 | }, 46 | outDir: `dist/${entrypoint}`, 47 | })), 48 | ...reactServerEntrypoints.map((reactEntrypoint) => ({ 49 | ...reactServerOptions, 50 | entry: { 51 | index: `src/${reactEntrypoint}.tsx`, 52 | }, 53 | outDir: `dist/${reactEntrypoint}`, 54 | })), 55 | ...reactClientEntrypoints.map((reactEntrypoint) => ({ 56 | ...reactClientOptions, 57 | entry: { 58 | index: `src/${reactEntrypoint}.tsx`, 59 | }, 60 | outDir: `dist/${reactEntrypoint}`, 61 | })), 62 | ]); 63 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "examples/*" 4 | - "packages/*" 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "downlevelIteration": true, 5 | "esModuleInterop": true, 6 | "incremental": true, 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "lib": ["es2019", "es2017", "dom"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "noImplicitAny": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "strictNullChecks": true, 21 | "target": "es2021", 22 | "types": ["node"] 23 | }, 24 | "exclude": ["node_modules", "**/dist/**"], 25 | "include": ["packages/**/*"], 26 | "watchOptions": { 27 | "watchFile": "useFsEvents", 28 | "watchDirectory": "useFsEvents", 29 | "fallbackPolling": "dynamicPriority" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env"], 4 | "globalEnv": ["NODE_ENV"], 5 | "pipeline": { 6 | "build": { 7 | "dependsOn": ["generate", "^build"], 8 | "env": [], 9 | "outputs": ["dist/**", ".next/**"] 10 | }, 11 | "dev": { 12 | "cache": false, 13 | "persistent": true 14 | }, 15 | "generate": { 16 | "cache": false 17 | }, 18 | "test": { 19 | "cache": false 20 | }, 21 | "lint": {}, 22 | "clean": { 23 | "cache": false 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ["text", "json", "html"], 7 | exclude: [ 8 | ...configDefaults.exclude, 9 | "**/examples/**", 10 | "**/test/**", 11 | "**/*.d.ts", 12 | "**/generate-version.ts", 13 | ], 14 | }, 15 | environment: "jsdom", 16 | setupFiles: [ 17 | "./packages/core/test/setup.ts", 18 | "./packages/react/test/setup.ts", 19 | ], 20 | }, 21 | }); 22 | --------------------------------------------------------------------------------