├── .devcontainer
└── devcontainer.json
├── .editorconfig
├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ ├── check-pr.yml
│ ├── codeql-analysis.yml
│ └── npm-publish.yml
├── .gitignore
├── .npmrc
├── .prettierrc.common.cjs
├── .vscode
└── extensions.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── FAQ.md
├── LICENSE
├── README.md
├── api_documentation.md
├── demo
├── .prettierignore
├── README.md
├── eslint.config.js
├── index.html
├── package.json
├── public
│ ├── favicon.ico
│ ├── mejs-controls.svg
│ └── modules-demo
│ │ ├── core
│ │ ├── hls-with-shaka.html
│ │ ├── hls-with-shaka.js
│ │ ├── hlsjs
│ │ ├── player-vime.html
│ │ └── shaka
├── src
│ ├── App.tsx
│ ├── app.css
│ ├── declarations.d.ts
│ ├── global.d.ts
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── eslint.common.config.js
├── eslint.common.react.config.js
├── package.json
├── packages
├── p2p-media-loader-core
│ ├── .editorconfig
│ ├── .prettierignore
│ ├── .prettierrc.cjs
│ ├── LICENSE
│ ├── README.md
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── bandwidth-calculator.ts
│ │ ├── core.ts
│ │ ├── declarations.d.ts
│ │ ├── http-loader.ts
│ │ ├── hybrid-loader.ts
│ │ ├── index.ts
│ │ ├── internal-types.ts
│ │ ├── p2p
│ │ │ ├── commands
│ │ │ │ ├── binary-command-creator.ts
│ │ │ │ ├── binary-serialization.ts
│ │ │ │ ├── commands.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ │ ├── loader.ts
│ │ │ ├── loaders-container.ts
│ │ │ ├── peer-protocol.ts
│ │ │ ├── peer.ts
│ │ │ └── tracker-client.ts
│ │ ├── requests
│ │ │ ├── engine-request.ts
│ │ │ ├── request-container.ts
│ │ │ └── request.ts
│ │ ├── segment-storage
│ │ │ ├── index.ts
│ │ │ ├── segment-memory-storage.ts
│ │ │ └── utils.ts
│ │ ├── types.ts
│ │ └── utils
│ │ │ ├── event-target.ts
│ │ │ ├── logger.ts
│ │ │ ├── peer.ts
│ │ │ ├── queue.ts
│ │ │ ├── stream.ts
│ │ │ ├── utils.ts
│ │ │ └── version.ts
│ ├── test
│ │ └── utils.test.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── typedoc.json
│ └── vite.config.ts
├── p2p-media-loader-demo
│ ├── README.md
│ ├── dummy.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── components
│ │ │ ├── P2PVideoDemo.tsx
│ │ │ ├── PlaybackOptions.tsx
│ │ │ ├── chart
│ │ │ │ ├── ChartLegend.tsx
│ │ │ │ ├── DownloadStatsChart.tsx
│ │ │ │ ├── chart.css
│ │ │ │ └── drawChart.ts
│ │ │ ├── debugTools
│ │ │ │ ├── DebugSelector.tsx
│ │ │ │ └── DebugTools.tsx
│ │ │ ├── demo.css
│ │ │ ├── nodeNetwork
│ │ │ │ ├── NodeNetwork.tsx
│ │ │ │ ├── network.css
│ │ │ │ └── network.ts
│ │ │ └── players
│ │ │ │ ├── clappr.css
│ │ │ │ ├── hlsjs
│ │ │ │ ├── Hlsjs.tsx
│ │ │ │ ├── HlsjsClapprPlayer.tsx
│ │ │ │ ├── HlsjsDPLayer.tsx
│ │ │ │ ├── HlsjsMediaElement.tsx
│ │ │ │ ├── HlsjsOpenPlayer.tsx
│ │ │ │ ├── HlsjsPlyr.tsx
│ │ │ │ ├── HlsjsVidstack.tsx
│ │ │ │ ├── HlsjsVidstackIndexedDB.tsx
│ │ │ │ ├── hlsjs.css
│ │ │ │ └── vidstack_indexed_db.css
│ │ │ │ ├── loader
│ │ │ │ ├── Loader.tsx
│ │ │ │ └── loader.css
│ │ │ │ ├── shaka
│ │ │ │ ├── Shaka.tsx
│ │ │ │ ├── ShakaClappr.tsx
│ │ │ │ ├── ShakaDPlayer.tsx
│ │ │ │ ├── ShakaPlyr.tsx
│ │ │ │ └── shaka-import.ts
│ │ │ │ └── utils.ts
│ │ ├── constants.ts
│ │ ├── custom-segment-storage-example
│ │ │ ├── indexed-db-storage.ts
│ │ │ └── indexed-db-wrapper.ts
│ │ ├── hooks
│ │ │ ├── useQueryParams.ts
│ │ │ └── useScripts.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── tsconfig.json
│ └── tsconfig.node.json
├── p2p-media-loader-hlsjs
│ ├── .editorconfig
│ ├── .prettierignore
│ ├── .prettierrc.cjs
│ ├── LICENSE
│ ├── README.md
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── engine-static.ts
│ │ ├── engine.ts
│ │ ├── fragment-loader.ts
│ │ ├── index.ts
│ │ ├── playlist-loader.ts
│ │ ├── segment-mananger.ts
│ │ └── utils.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── typedoc.json
│ └── vite.config.ts
└── p2p-media-loader-shaka
│ ├── .editorconfig
│ ├── .prettierignore
│ ├── .prettierrc.cjs
│ ├── LICENSE
│ ├── README.md
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ ├── engine.ts
│ ├── index.ts
│ ├── loading-handler.ts
│ ├── manifest-parser-decorator.ts
│ ├── segment-manager.ts
│ ├── stream-utils.ts
│ └── types.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── typedoc.json
│ └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── scripts
└── update-versions.js
├── tsconfig.base.json
├── typedoc.json
└── typedoc
└── styles.css
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
3 | {
4 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
5 | // "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
6 |
7 | // Features to add to the dev container. More info: https://containers.dev/features.
8 | // "features": {
9 | // "ghcr.io/devcontainers/features/node:1": {}
10 | // },
11 |
12 | "postCreateCommand": {
13 | "update pnpm": "npm add -g pnpm",
14 | "dependencies": "pnpm install"
15 | },
16 |
17 | "postAttachCommand": "# Welcome to your Codespace! Run `pnpm dev` to start development.",
18 |
19 | // Configure tool-specific properties.
20 | "customizations": {
21 | "vscode": {
22 | "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
23 | "settings": {
24 | "editor.formatOnSave": true,
25 | "[javascript]": {
26 | "editor.defaultFormatter": "esbenp.prettier-vscode"
27 | },
28 | "[typescript]": {
29 | "editor.defaultFormatter": "esbenp.prettier-vscode"
30 | },
31 | "[json]": {
32 | "editor.defaultFormatter": "esbenp.prettier-vscode"
33 | },
34 | "[jsonl]": {
35 | "editor.defaultFormatter": "esbenp.prettier-vscode"
36 | },
37 | "[jsonc]": {
38 | "editor.defaultFormatter": "esbenp.prettier-vscode"
39 | }
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: https://www.blockchain.com/btc/address/12YW9DJXAucLAx6Gy9tAXgXUPstHXEHnPY
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for more information:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 | # https://containers.dev/guide/dependabot
6 |
7 | version: 2
8 | updates:
9 | - package-ecosystem: "devcontainers"
10 | directory: "/"
11 | schedule:
12 | interval: weekly
13 |
--------------------------------------------------------------------------------
/.github/workflows/check-pr.yml:
--------------------------------------------------------------------------------
1 | name: Build & Lint
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: pnpm/action-setup@v4
16 | with:
17 | version: latest
18 |
19 | - uses: actions/setup-node@v4
20 | with:
21 | node-version: 'lts/*'
22 |
23 | - run: pnpm i
24 | - run: pnpm lint
25 | - run: pnpm build
26 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | schedule:
8 | - cron: "0 17 * * 2"
9 |
10 | jobs:
11 | analyze:
12 | name: Analyze
13 | runs-on: ubuntu-latest
14 | timeout-minutes: 360
15 | permissions:
16 | security-events: write
17 | actions: read
18 | contents: read
19 |
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | language: ["javascript-typescript"]
24 |
25 | steps:
26 | - name: Checkout repository
27 | uses: actions/checkout@v4
28 |
29 | - name: Initialize CodeQL
30 | uses: github/codeql-action/init@v3
31 | with:
32 | languages: ${{ matrix.language }}
33 |
34 | - name: Autobuild
35 | uses: github/codeql-action/autobuild@v3
36 |
37 | - name: Perform CodeQL Analysis
38 | uses: github/codeql-action/analyze@v3
39 | with:
40 | category: "/language:${{matrix.language}}"
41 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to npm
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | setup_and_build:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup Node.js
18 | uses: actions/setup-node@v4
19 | with:
20 | registry-url: "https://registry.npmjs.org/"
21 | node-version: 'lts/*'
22 |
23 | - name: Setup pnpm
24 | uses: pnpm/action-setup@v4
25 | with:
26 | version: latest
27 |
28 | - name: Install dependencies
29 | run: pnpm install
30 |
31 | - name: Extract version from tag
32 | id: get_version
33 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
34 |
35 | - name: Update package.json and WebTorrent client versions
36 | run: |
37 | export TAG=$VERSION
38 | node update-versions.js
39 | working-directory: ./scripts
40 |
41 | - name: Build
42 | run: pnpm run build
43 |
44 | - name: Override symlinks
45 | run: |
46 | for d in ./packages/*; do
47 | rm "$d"/README.md
48 | cp ./README.md "$d"
49 | done
50 |
51 | - name: Pack packages
52 | run: pnpm run pack-packages
53 |
54 | - name: Publish packages
55 | env:
56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
57 | run: |
58 | pnpm publish --access public --no-git-checks --filter './packages/**'
59 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | docs
11 | node_modules
12 | /demo/dist
13 | /packages/*/dist
14 | /packages/*/lib
15 | /packages/*/build
16 | /packages/*/dist-ssr
17 | *.local
18 | *.tgz
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | .DS_Store
25 | *.suo
26 | *.ntvs*
27 | *.njsproj
28 | *.sln
29 | *.sw?
30 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*eslint*
2 |
--------------------------------------------------------------------------------
/.prettierrc.common.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | editorconfig: true,
3 | };
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
2 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
3 | {
4 | // List of extensions which should be recommended for users of this workspace.
5 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
6 |
7 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
8 | "unwantedRecommendations": []
9 | }
10 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | issues and discussions tabs.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Welcome to the P2P Media Loader Contributing Guide
2 |
3 | Thank you for investing your time in contributing to our project! We appreciate every pull request, issue report, and suggestion you make to help improve **P2P Media Loader**.
4 |
5 | ## Table of Contents
6 |
7 | - [Using GitHub Codespaces](#using-github-codespaces)
8 | - [Developing Locally](#developing-locally)
9 | - [Contributing Process](#contributing-process)
10 |
11 | ## Using GitHub Codespaces
12 |
13 | The easiest way to contribute is to use **GitHub Codespaces**, which are already preconfigured in this repository.
14 |
15 | 1. **Fork** the repository (optional if you don’t have write access).
16 | 2. **Create a Codespace** from the repository (click the green “Code” button, then choose **Codespaces**).
17 | 3. Once the Codespace is ready, open the integrated terminal.
18 | 4. Run `pnpm dev` to start the development environment.
19 |
20 | That’s it! All required dependencies and tools are pre-installed, so you can begin coding, testing, and debugging immediately.
21 |
22 | ## Developing Locally
23 |
24 | If you prefer to develop without Codespaces, follow these steps:
25 |
26 | 0. **Ensure** you have [pnpm installed](https://pnpm.io/installation) globally if you haven’t already.
27 |
28 | 1. **Clone** the repository:
29 |
30 | ```bash
31 | git clone https://github.com/Novage/p2p-media-loader.git
32 | ```
33 |
34 | 2. **Install dependencies** for all workspace projects:
35 |
36 | ```bash
37 | pnpm i
38 | ```
39 |
40 | 3. **Start development** mode:
41 |
42 | ```bash
43 | pnpm dev
44 | ```
45 |
46 | ## Contributing Process
47 |
48 | Please follow [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow) when collaborating on our project.
49 |
50 | 1. **Open an issue**: If you find a bug or have a feature request, start by creating a new issue to discuss.
51 |
52 | 2. **Fork the repository** (if you don’t have direct commit access).
53 |
54 | 3. **Create a new branch** for your changes:
55 |
56 | ```bash
57 | git checkout -b feature/your-feature
58 | ```
59 |
60 | 4. **Make your changes** and commit them with a descriptive message.
61 |
62 | 5. **Push your branch** to GitHub:
63 |
64 | ```bash
65 | git push origin feature/your-feature
66 | ```
67 |
68 | 6. **Open a pull request**: Go to the repository on GitHub, click the “Compare & pull request” button, and fill out the PR template. Describe your changes clearly so reviewers know what you did and why.
69 |
70 | We’ll review your pull request, provide feedback, and merge your changes once everything looks good.
71 |
--------------------------------------------------------------------------------
/demo/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | dist
4 | .gitignore
5 | README.md
6 | LICENSE
7 | package-lock.json
8 |
--------------------------------------------------------------------------------
/demo/README.md:
--------------------------------------------------------------------------------
1 | ../README.md
--------------------------------------------------------------------------------
/demo/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import tsEslint from "typescript-eslint";
4 | import { CommonReactConfig } from "../eslint.common.react.config.js";
5 |
6 | export default tsEslint.config(...CommonReactConfig);
7 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | P2P Media Loader Demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dev-demo",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "prettier": "prettier --write .",
12 | "type-check": "npx tsc --noEmit",
13 | "clean": "rimraf dist",
14 | "clean-with-modules": "rimraf node_modules && pnpm clean"
15 | },
16 | "dependencies": {
17 | "p2p-media-loader-core": "workspace:*",
18 | "p2p-media-loader-demo": "workspace:*",
19 | "p2p-media-loader-hlsjs": "workspace:*",
20 | "p2p-media-loader-shaka": "workspace:*",
21 | "react": "^19.0.0",
22 | "react-dom": "^19.0.0"
23 | },
24 | "devDependencies": {
25 | "@types/react": "^19.0.10",
26 | "@types/react-dom": "^19.0.4",
27 | "@vitejs/plugin-react": "^4.3.4",
28 | "eslint-plugin-react": "^7.37.4",
29 | "eslint-plugin-react-hooks": "^5.2.0",
30 | "eslint-plugin-react-refresh": "^0.4.19",
31 | "vite-plugin-node-polyfills": "^0.23.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novage/p2p-media-loader/aa017813f8658033fb0e241ed7b2d2c25f31ccb3/demo/public/favicon.ico
--------------------------------------------------------------------------------
/demo/public/mejs-controls.svg:
--------------------------------------------------------------------------------
1 |
2 |
69 |
--------------------------------------------------------------------------------
/demo/public/modules-demo/core:
--------------------------------------------------------------------------------
1 | ../../../packages/p2p-media-loader-core/dist
--------------------------------------------------------------------------------
/demo/public/modules-demo/hls-with-shaka.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/demo/public/modules-demo/hls-with-shaka.js:
--------------------------------------------------------------------------------
1 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
2 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
3 |
4 | const manifestUri = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
5 |
6 | async function initApp() {
7 | if (shaka.Player.isBrowserSupported()) {
8 | initHlsPlayer("video1");
9 | await initShakaPlayer("video2");
10 | } else {
11 | console.error("Browser not supported!");
12 | }
13 | }
14 |
15 | function initHlsPlayer(videoId) {
16 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(window.Hls);
17 | const hls = new HlsWithP2P();
18 | hls.attachMedia(document.getElementById(videoId));
19 | hls.on(Hls.Events.ERROR, function (event, data) {
20 | console.error("Error code", data.details, "object", data);
21 | });
22 |
23 | try {
24 | hls.loadSource(manifestUri);
25 | } catch (e) {
26 | onError(e);
27 | }
28 | }
29 |
30 | async function initShakaPlayer(videoId) {
31 | ShakaP2PEngine.registerPlugins();
32 | const engine = new ShakaP2PEngine();
33 |
34 | const player = new shaka.Player();
35 | await player.attach(document.getElementById(videoId));
36 | player.addEventListener("error", onErrorEvent);
37 |
38 | engine.bindShakaPlayer(player);
39 |
40 | try {
41 | await player.load(manifestUri);
42 | } catch (e) {
43 | onError(e);
44 | }
45 | }
46 |
47 | function onErrorEvent(event) {
48 | onError(event.detail);
49 | }
50 |
51 | function onError(error) {
52 | console.error("Error code", error.code, "object", error);
53 | }
54 |
55 | document.addEventListener("DOMContentLoaded", initApp);
56 |
--------------------------------------------------------------------------------
/demo/public/modules-demo/hlsjs:
--------------------------------------------------------------------------------
1 | ../../../packages/p2p-media-loader-hlsjs/dist
--------------------------------------------------------------------------------
/demo/public/modules-demo/player-vime.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
15 |
46 |
47 |
52 |
53 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/demo/public/modules-demo/shaka:
--------------------------------------------------------------------------------
1 | ../../../packages/p2p-media-loader-shaka/dist
--------------------------------------------------------------------------------
/demo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "./app.css";
2 | import { P2PVideoDemo } from "p2p-media-loader-demo";
3 |
4 | export function App() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/demo/src/app.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Titillium Web";
3 | font-display: swap;
4 | font-style: normal;
5 | font-weight: 400;
6 | src:
7 | local("Titillium Web Regular"),
8 | local("TitilliumWeb-Regular"),
9 | url(https://fonts.gstatic.com/s/titilliumweb/v7/NaPecZTIAOhVxoMyOr9n_E7fdMPmDQ.woff2)
10 | format("woff2");
11 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
12 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
13 | U+FEFF, U+FFFD;
14 | }
15 |
16 | @font-face {
17 | font-family: "Font Awesome 5 Free";
18 | font-style: normal;
19 | font-weight: 900;
20 | font-display: swap;
21 | src: url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.eot);
22 | src:
23 | url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.eot?#iefix)
24 | format("embedded-opentype"),
25 | url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.woff2)
26 | format("woff2"),
27 | url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.woff)
28 | format("woff"),
29 | url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.ttf)
30 | format("truetype"),
31 | url(https://use.fontawesome.com/releases/v5.9.0/webfonts/fa-solid-900.svg#fontawesome)
32 | format("svg");
33 | }
34 |
35 | body {
36 | display: flex;
37 | flex-direction: column;
38 | height: 100%;
39 | font-family: "Titillium Web", sans-serif;
40 | font-size: 1em;
41 | font-weight: 400;
42 | line-height: 1.5;
43 | margin: 0;
44 | padding: 0;
45 | }
46 |
--------------------------------------------------------------------------------
/demo/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module "mux.js";
2 | declare module "shaka-player";
3 | declare module "@clappr/player";
4 |
--------------------------------------------------------------------------------
/demo/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | Clappr: any;
5 | LevelSelector: unknown;
6 | DashShakaPlayback: unknown;
7 | }
8 | }
9 |
10 | export {};
11 |
--------------------------------------------------------------------------------
/demo/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { App } from "./App";
4 |
5 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/demo/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | declare const __VERSION__: string;
3 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 | "moduleResolution": "Bundler",
8 | "allowImportingTsExtensions": true,
9 | "resolveJsonModule": true,
10 | "isolatedModules": true,
11 | "noEmit": true,
12 | "jsx": "react-jsx",
13 | "strict": true,
14 | "noUnusedLocals": false,
15 | "noUnusedParameters": false,
16 | "noFallthroughCasesInSwitch": true,
17 | "allowSyntheticDefaultImports": true,
18 | "useDefineForClassFields": true
19 | },
20 | "include": ["src"],
21 | "references": [{ "path": "./tsconfig.node.json" }]
22 | }
23 |
--------------------------------------------------------------------------------
/demo/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext"
6 | },
7 | "include": ["vite.config.ts", "eslint.config.js"]
8 | }
9 |
--------------------------------------------------------------------------------
/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { nodePolyfills } from "vite-plugin-node-polyfills";
4 |
5 | export default defineConfig({
6 | server: { open: true, host: true },
7 | plugins: [nodePolyfills(), react()],
8 | });
9 |
--------------------------------------------------------------------------------
/eslint.common.config.js:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import eslint from "@eslint/js";
3 | import tsEslint from "typescript-eslint";
4 | import { flatConfigs as importPlugin } from "eslint-plugin-import";
5 |
6 | export const CommonConfig =
7 | /** @type {(typeof tsEslint.configs.eslintRecommended)[]} */ ([
8 | eslint.configs.recommended,
9 | importPlugin.recommended,
10 | tsEslint.configs.eslintRecommended,
11 | ...tsEslint.configs.strictTypeChecked,
12 | ...tsEslint.configs.stylisticTypeChecked,
13 | {
14 | languageOptions: {
15 | globals: {
16 | ...globals.browser,
17 | },
18 |
19 | ecmaVersion: 2022,
20 | sourceType: "module",
21 |
22 | parserOptions: {
23 | project: ["tsconfig.json", "tsconfig.node.json"],
24 | },
25 | },
26 |
27 | rules: {
28 | "@typescript-eslint/consistent-type-definitions": "off",
29 | "@typescript-eslint/no-unused-vars": [
30 | "warn",
31 | { argsIgnorePattern: "^_" },
32 | ],
33 | "@typescript-eslint/restrict-template-expressions": [
34 | "error",
35 | { allowNumber: true },
36 | ],
37 | "@typescript-eslint/no-confusing-void-expression": [
38 | "error",
39 | { ignoreArrowShorthand: true },
40 | ],
41 | "@typescript-eslint/no-unnecessary-condition": [
42 | "error",
43 | { allowConstantLoopConditions: true },
44 | ],
45 |
46 | "no-var": "error",
47 | "no-alert": "warn",
48 | "prefer-const": "error",
49 | "prefer-spread": "error",
50 | "no-multi-assign": "error",
51 | "prefer-template": "error",
52 | "object-shorthand": "error",
53 | "no-nested-ternary": "error",
54 | "no-array-constructor": "error",
55 | "prefer-object-spread": "error",
56 | "prefer-arrow-callback": "error",
57 | "prefer-destructuring": ["error", { object: true, array: false }],
58 | "no-console": "warn",
59 | curly: ["warn", "multi-line", "consistent"],
60 | "no-debugger": "warn",
61 | "spaced-comment": ["warn", "always", { markers: ["/"] }],
62 |
63 | "import/no-unresolved": "off",
64 | "import/named": "off",
65 | "import/no-named-as-default": "off",
66 | "import/namespace": "off",
67 | "import/no-named-as-default-member": "off",
68 | "import/extensions": [
69 | "error",
70 | "always",
71 | {
72 | js: "ignorePackages",
73 | jsx: "never",
74 | ts: "never",
75 | tsx: "never",
76 | },
77 | ],
78 | },
79 | },
80 | ]);
81 |
--------------------------------------------------------------------------------
/eslint.common.react.config.js:
--------------------------------------------------------------------------------
1 | import { CommonConfig } from "./eslint.common.config.js";
2 | import reactRefresh from "eslint-plugin-react-refresh";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactPlugin from "eslint-plugin-react";
5 | import react from "@eslint-react/eslint-plugin";
6 |
7 | export const CommonReactConfig = /** @type {typeof CommonConfig} */ ([
8 | ...CommonConfig,
9 | reactPlugin.configs.flat?.recommended,
10 | reactPlugin.configs.flat?.["jsx-runtime"],
11 | react.configs.all,
12 | {
13 | plugins: {
14 | "react-hooks": reactHooks,
15 | "react-refresh": reactRefresh,
16 | },
17 | rules: {
18 | ...reactHooks.configs.recommended.rules,
19 | "import/extensions": "off",
20 | "@eslint-react/avoid-shorthand-fragment": "off",
21 | "@eslint-react/avoid-shorthand-boolean": "off",
22 | "@eslint-react/naming-convention/filename": "off",
23 | },
24 | settings: {
25 | react: {
26 | version: "detect",
27 | },
28 | },
29 | },
30 | ]);
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-media-loader",
3 | "version": "1.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "pnpm --recursive build",
8 | "build:es": "pnpm --filter './packages/**' build:es",
9 | "build:esm": "pnpm --filter './packages/**' build:esm",
10 | "build:esm-min": "pnpm --filter './packages/**' build:esm-min",
11 | "clean": "pnpm --recursive clean",
12 | "clean-with-modules": "pnpm --recursive clean-with-modules && rimraf node_modules",
13 | "pack-packages": "pnpm --filter './packages/**' exec -- pnpm pack",
14 | "lint": "pnpm --recursive lint",
15 | "prettier": "pnpm --recursive prettier",
16 | "type-check": "pnpm --recursive type-check",
17 | "dev": "pnpm --filter './demo' dev",
18 | "create-doc": "pnpm typedoc"
19 | },
20 | "devDependencies": {
21 | "@eslint-react/eslint-plugin": "^1.31.0",
22 | "eslint": "^9.22.0",
23 | "eslint-plugin-import": "^2.31.0",
24 | "globals": "^16.0.0",
25 | "prettier": "^3.5.3",
26 | "rimraf": "^6.0.1",
27 | "typedoc": "^0.27.9",
28 | "typedoc-material-theme": "^1.3.0",
29 | "typescript": "^5.8.2",
30 | "typescript-eslint": "^8.26.0",
31 | "vite": "^6.2.1"
32 | },
33 | "pnpm": {
34 | "onlyBuiltDependencies": [
35 | "bufferutil",
36 | "core-js",
37 | "esbuild",
38 | "node-datachannel",
39 | "utf-8-validate"
40 | ]
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/.editorconfig:
--------------------------------------------------------------------------------
1 | root = false
2 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | dist
4 | .gitignore
5 | README.md
6 | LICENSE
7 | package-lock.json
8 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require("../../.prettierrc.common.cjs"),
3 | };
4 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/README.md:
--------------------------------------------------------------------------------
1 | ../../README.md
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { CommonConfig } from "../../eslint.common.config.js";
4 | import tsEslint from "typescript-eslint";
5 |
6 | export default tsEslint.config(...CommonConfig);
7 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-media-loader-core",
3 | "description": "P2P Media Loader core functionality",
4 | "license": "Apache-2.0",
5 | "author": "Novage",
6 | "homepage": "https://github.com/Novage/p2p-media-loader",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Novage/p2p-media-loader",
10 | "directory": "packages/p2p-media-loader-core"
11 | },
12 | "keywords": [
13 | "p2p",
14 | "peer-to-peer",
15 | "hls",
16 | "dash",
17 | "webrtc",
18 | "video",
19 | "mse",
20 | "player",
21 | "torrent",
22 | "bittorrent",
23 | "webtorrent",
24 | "hlsjs",
25 | "shaka player",
26 | "ecdn",
27 | "cdn"
28 | ],
29 | "version": "2.1.0",
30 | "files": [
31 | "dist",
32 | "lib",
33 | "src"
34 | ],
35 | "exports": "./src/index.ts",
36 | "types": "./src/index.ts",
37 | "publishConfig": {
38 | "exports": {
39 | ".": {
40 | "p2pml:core-as-bundle": "./dist/p2p-media-loader-core.es.js",
41 | "import": "./lib/index.js"
42 | }
43 | },
44 | "types": "./lib/index.d.ts"
45 | },
46 | "sideEffects": false,
47 | "type": "module",
48 | "scripts": {
49 | "dev": "vite",
50 | "build": "rimraf lib build && pnpm build:es && pnpm build:esm && pnpm build:esm-min",
51 | "build:esm": "vite build --mode esm",
52 | "build:esm-min": "vite build --mode esm-min",
53 | "build:es": "tsc",
54 | "prettier": "prettier --write .",
55 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
56 | "clean": "rimraf lib dist build p2p-media-loader-core-*.tgz",
57 | "clean-with-modules": "rimraf node_modules && pnpm clean",
58 | "type-check": "npx tsc --noEmit",
59 | "test": "vitest"
60 | },
61 | "dependencies": {
62 | "@types/debug": "^4.1.12",
63 | "bittorrent-tracker": "^11.2.1",
64 | "debug": "^4.4.0",
65 | "nano-md5": "^1.0.5"
66 | },
67 | "devDependencies": {
68 | "@rollup/plugin-terser": "^0.4.4",
69 | "@types/streamx": "^2.9.5",
70 | "vite-plugin-node-polyfills": "^0.23.0",
71 | "vitest": "^3.0.8"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/bandwidth-calculator.ts:
--------------------------------------------------------------------------------
1 | export class BandwidthCalculator {
2 | private loadingsCount = 0;
3 | private readonly bytes: number[] = [];
4 | private readonly loadingOnlyTimestamps: number[] = [];
5 | private readonly timestamps: number[] = [];
6 | private noLoadingsTime = 0;
7 | private loadingsStoppedAt = 0;
8 |
9 | constructor(private readonly clearThresholdMs = 20000) {}
10 |
11 | addBytes(bytesLength: number, now = performance.now()) {
12 | this.bytes.push(bytesLength);
13 | this.loadingOnlyTimestamps.push(now - this.noLoadingsTime);
14 | this.timestamps.push(now);
15 | }
16 |
17 | startLoading(now = performance.now()) {
18 | this.clearStale();
19 | if (this.loadingsCount === 0 && this.loadingsStoppedAt !== 0) {
20 | this.noLoadingsTime += now - this.loadingsStoppedAt;
21 | }
22 | this.loadingsCount++;
23 | }
24 |
25 | stopLoading(now = performance.now()) {
26 | if (this.loadingsCount > 0) {
27 | this.loadingsCount--;
28 | if (this.loadingsCount === 0) this.loadingsStoppedAt = now;
29 | }
30 | }
31 |
32 | getBandwidthLoadingOnly(
33 | seconds: number,
34 | ignoreThresholdTimestamp = Number.NEGATIVE_INFINITY,
35 | ) {
36 | if (!this.loadingOnlyTimestamps.length) return 0;
37 | const milliseconds = seconds * 1000;
38 | const lastItemTimestamp =
39 | this.loadingOnlyTimestamps[this.loadingOnlyTimestamps.length - 1];
40 | let lastCountedTimestamp = lastItemTimestamp;
41 | const threshold = lastItemTimestamp - milliseconds;
42 | let totalBytes = 0;
43 |
44 | for (let i = this.bytes.length - 1; i >= 0; i--) {
45 | const timestamp = this.loadingOnlyTimestamps[i];
46 | if (
47 | timestamp < threshold ||
48 | this.timestamps[i] < ignoreThresholdTimestamp
49 | ) {
50 | break;
51 | }
52 | lastCountedTimestamp = timestamp;
53 | totalBytes += this.bytes[i];
54 | }
55 |
56 | return (totalBytes * 8000) / (lastItemTimestamp - lastCountedTimestamp);
57 | }
58 |
59 | getBandwidth(
60 | seconds: number,
61 | ignoreThresholdTimestamp = Number.NEGATIVE_INFINITY,
62 | now = performance.now(),
63 | ) {
64 | if (!this.timestamps.length) return 0;
65 | const milliseconds = seconds * 1000;
66 | const threshold = now - milliseconds;
67 | let lastCountedTimestamp = now;
68 | let totalBytes = 0;
69 |
70 | for (let i = this.bytes.length - 1; i >= 0; i--) {
71 | const timestamp = this.timestamps[i];
72 | if (timestamp < threshold || timestamp < ignoreThresholdTimestamp) break;
73 | lastCountedTimestamp = timestamp;
74 | totalBytes += this.bytes[i];
75 | }
76 |
77 | return (totalBytes * 8000) / (now - lastCountedTimestamp);
78 | }
79 |
80 | clearStale() {
81 | if (!this.loadingOnlyTimestamps.length) return;
82 | const threshold =
83 | this.loadingOnlyTimestamps[this.loadingOnlyTimestamps.length - 1] -
84 | this.clearThresholdMs;
85 |
86 | let samplesToRemove = 0;
87 | for (const timestamp of this.loadingOnlyTimestamps) {
88 | if (timestamp > threshold) break;
89 | samplesToRemove++;
90 | }
91 |
92 | this.bytes.splice(0, samplesToRemove);
93 | this.loadingOnlyTimestamps.splice(0, samplesToRemove);
94 | this.timestamps.splice(0, samplesToRemove);
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module "bittorrent-tracker" {
2 | import type { Duplex, WritableEvents } from "streamx";
3 |
4 | export default class Client {
5 | constructor(options: {
6 | infoHash: Uint8Array;
7 | peerId: Uint8Array;
8 | announce: string[];
9 | rtcConfig?: RTCConfiguration;
10 | getAnnounceOpts?: () => object;
11 | });
12 |
13 | on(
14 | event: E,
15 | handler: TrackerClientEvents[E],
16 | ): void;
17 |
18 | start(): void;
19 | destroy(): void;
20 | }
21 |
22 | export type TrackerClientEvents = {
23 | update: (data: object) => void;
24 | peer: (peer: PeerConnection) => void;
25 | warning: (warning: unknown) => void;
26 | error: (error: unknown) => void;
27 | };
28 |
29 | export type PeerEvents = {
30 | connect: () => void;
31 | } & WritableEvents;
32 |
33 | export type PeerConnection = Duplex & {
34 | id: string;
35 | idUtf8: string;
36 | initiator: boolean;
37 | on(event: E, handler: PeerEvents[E]): void;
38 | off(event: E, handler: PeerEvents[E]): void;
39 | send(data: string | ArrayBuffer): void;
40 | };
41 | }
42 |
43 | declare module "nano-md5" {
44 | type BinaryStringObject = string & { toHex: () => string };
45 | const md5: {
46 | (utf8String: string): string; // returns hex string interpretation of binary data
47 | fromUtf8(utf8String: string): BinaryStringObject;
48 | };
49 |
50 | export default md5;
51 | }
52 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Core } from "./core.js";
2 | export * from "./types.js";
3 | export type { SegmentStorage } from "./segment-storage/index.js";
4 | export { debug } from "debug";
5 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/internal-types.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { BandwidthCalculator } from "./bandwidth-calculator.js";
4 |
5 | export type Playback = {
6 | position: number;
7 | rate: number;
8 | };
9 |
10 | export type BandwidthCalculators = Readonly<{
11 | all: BandwidthCalculator;
12 | http: BandwidthCalculator;
13 | }>;
14 |
15 | export type StreamDetails = {
16 | isLive: boolean;
17 | activeLevelBitrate: number;
18 | };
19 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/p2p/commands/commands.ts:
--------------------------------------------------------------------------------
1 | import { BinaryCommandCreator } from "./binary-command-creator.js";
2 | import {
3 | PeerSegmentCommand,
4 | PeerSendSegmentCommand,
5 | PeerSegmentAnnouncementCommand,
6 | PeerRequestSegmentCommand,
7 | PeerCommand,
8 | PeerCommandType,
9 | } from "./types.js";
10 |
11 | function serializeSegmentAnnouncementCommand(
12 | command: PeerSegmentAnnouncementCommand,
13 | maxChunkSize: number,
14 | ) {
15 | const { c: commandCode, p: loadingByHttp, l: loaded } = command;
16 | const creator = new BinaryCommandCreator(commandCode, maxChunkSize);
17 | if (loaded?.length) creator.addSimilarIntArr("l", loaded);
18 | if (loadingByHttp?.length) {
19 | creator.addSimilarIntArr("p", loadingByHttp);
20 | }
21 | creator.complete();
22 | return creator.getResultBuffers();
23 | }
24 |
25 | function serializePeerSegmentCommand(
26 | command: PeerSegmentCommand,
27 | maxChunkSize: number,
28 | ) {
29 | const creator = new BinaryCommandCreator(command.c, maxChunkSize);
30 | creator.addInteger("i", command.i);
31 | creator.addInteger("r", command.r);
32 | creator.complete();
33 | return creator.getResultBuffers();
34 | }
35 |
36 | function serializePeerSendSegmentCommand(
37 | command: PeerSendSegmentCommand,
38 | maxChunkSize: number,
39 | ) {
40 | const creator = new BinaryCommandCreator(command.c, maxChunkSize);
41 | creator.addInteger("i", command.i);
42 | creator.addInteger("s", command.s);
43 | creator.addInteger("r", command.r);
44 | creator.complete();
45 | return creator.getResultBuffers();
46 | }
47 |
48 | function serializePeerSegmentRequestCommand(
49 | command: PeerRequestSegmentCommand,
50 | maxChunkSize: number,
51 | ) {
52 | const creator = new BinaryCommandCreator(command.c, maxChunkSize);
53 | creator.addInteger("i", command.i);
54 | creator.addInteger("r", command.r);
55 | if (command.b) creator.addInteger("b", command.b);
56 | creator.complete();
57 | return creator.getResultBuffers();
58 | }
59 |
60 | export function serializePeerCommand(
61 | command: PeerCommand,
62 | maxChunkSize: number,
63 | ) {
64 | switch (command.c) {
65 | case PeerCommandType.CancelSegmentRequest:
66 | case PeerCommandType.SegmentAbsent:
67 | case PeerCommandType.SegmentDataSendingCompleted:
68 | return serializePeerSegmentCommand(command, maxChunkSize);
69 | case PeerCommandType.SegmentRequest:
70 | return serializePeerSegmentRequestCommand(command, maxChunkSize);
71 | case PeerCommandType.SegmentsAnnouncement:
72 | return serializeSegmentAnnouncementCommand(command, maxChunkSize);
73 | case PeerCommandType.SegmentData:
74 | return serializePeerSendSegmentCommand(command, maxChunkSize);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/p2p/commands/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./types.js";
2 | export { serializePeerCommand } from "./commands.js";
3 | export {
4 | deserializeCommand,
5 | isCommandChunk,
6 | BinaryCommandChunksJoiner,
7 | BinaryCommandJoiningError,
8 | } from "./binary-command-creator.js";
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/p2p/commands/types.ts:
--------------------------------------------------------------------------------
1 | type BasePeerCommand = {
2 | c: T;
3 | };
4 |
5 | export const enum PeerCommandType {
6 | SegmentsAnnouncement,
7 | SegmentRequest,
8 | SegmentData,
9 | SegmentDataSendingCompleted,
10 | SegmentAbsent,
11 | CancelSegmentRequest,
12 | }
13 |
14 | export type PeerSegmentCommand = BasePeerCommand<
15 | | PeerCommandType.SegmentAbsent
16 | | PeerCommandType.CancelSegmentRequest
17 | | PeerCommandType.SegmentDataSendingCompleted
18 | > & {
19 | i: number; // segment id
20 | r: number; // request id
21 | };
22 |
23 | export type PeerRequestSegmentCommand =
24 | BasePeerCommand & {
25 | i: number; // segment id
26 | r: number; // request id
27 | b?: number; // byte from
28 | };
29 |
30 | export type PeerSegmentAnnouncementCommand =
31 | BasePeerCommand & {
32 | l?: number[]; // loaded segments
33 | p?: number[]; // segments loading by http
34 | };
35 |
36 | export type PeerSendSegmentCommand =
37 | BasePeerCommand & {
38 | i: number; // segment id
39 | r: number; // request id
40 | s: number; // size in bytes
41 | };
42 |
43 | export type PeerCommand =
44 | | PeerSegmentCommand
45 | | PeerRequestSegmentCommand
46 | | PeerSegmentAnnouncementCommand
47 | | PeerSendSegmentCommand;
48 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/p2p/loaders-container.ts:
--------------------------------------------------------------------------------
1 | import { P2PLoader } from "./loader.js";
2 | import debug from "debug";
3 | import {
4 | CoreEventMap,
5 | Stream,
6 | StreamConfig,
7 | StreamWithSegments,
8 | SegmentStorage,
9 | } from "../index.js";
10 | import { RequestsContainer } from "../requests/request-container.js";
11 | import * as LoggerUtils from "../utils/logger.js";
12 | import { EventTarget } from "../utils/event-target.js";
13 | import * as StreamUtils from "../utils/stream.js";
14 |
15 | type P2PLoaderContainerItem = {
16 | stream: Stream;
17 | loader: P2PLoader;
18 | destroyTimeoutId?: number;
19 | loggerInfo: string;
20 | };
21 |
22 | export class P2PLoadersContainer {
23 | private readonly loaders = new Map();
24 | private _currentLoaderItem: P2PLoaderContainerItem;
25 | private readonly logger = debug("p2pml-core:p2p-loaders-container");
26 |
27 | constructor(
28 | private readonly streamManifestUrl: string,
29 | stream: StreamWithSegments,
30 | private readonly requests: RequestsContainer,
31 | private readonly segmentStorage: SegmentStorage,
32 | private readonly config: StreamConfig,
33 | private readonly eventTarget: EventTarget,
34 | private onSegmentAnnouncement: () => void,
35 | ) {
36 | this._currentLoaderItem = this.findOrCreateLoaderForStream(stream);
37 | this.logger(
38 | `set current p2p loader: ${LoggerUtils.getStreamString(stream)}`,
39 | );
40 | }
41 |
42 | private createLoader(stream: StreamWithSegments): P2PLoaderContainerItem {
43 | if (this.loaders.has(stream.runtimeId)) {
44 | throw new Error("Loader for this stream already exists");
45 | }
46 | const loader = new P2PLoader(
47 | this.streamManifestUrl,
48 | stream,
49 | this.requests,
50 | this.segmentStorage,
51 | this.config,
52 | this.eventTarget,
53 | () => {
54 | if (this._currentLoaderItem.loader === loader) {
55 | this.onSegmentAnnouncement();
56 | }
57 | },
58 | );
59 | const loggerInfo = LoggerUtils.getStreamString(stream);
60 | this.logger(`created new loader: ${loggerInfo}`);
61 | return {
62 | loader,
63 | stream,
64 | loggerInfo: LoggerUtils.getStreamString(stream),
65 | };
66 | }
67 |
68 | private findOrCreateLoaderForStream(stream: StreamWithSegments) {
69 | const loaderItem = this.loaders.get(stream.runtimeId);
70 | if (loaderItem) {
71 | clearTimeout(loaderItem.destroyTimeoutId);
72 | loaderItem.destroyTimeoutId = undefined;
73 | return loaderItem;
74 | } else {
75 | const loader = this.createLoader(stream);
76 | this.loaders.set(stream.runtimeId, loader);
77 | return loader;
78 | }
79 | }
80 |
81 | changeCurrentLoader(stream: StreamWithSegments) {
82 | const swarmId = this.config.swarmId ?? this.streamManifestUrl;
83 | const streamSwarmId = StreamUtils.getStreamSwarmId(
84 | swarmId,
85 | this._currentLoaderItem.stream,
86 | );
87 | const ids = this.segmentStorage.getStoredSegmentIds(swarmId, streamSwarmId);
88 | if (!ids.length) this.destroyAndRemoveLoader(this._currentLoaderItem);
89 | else this.setLoaderDestroyTimeout(this._currentLoaderItem);
90 |
91 | this._currentLoaderItem = this.findOrCreateLoaderForStream(stream);
92 |
93 | this.logger(
94 | `change current p2p loader: ${LoggerUtils.getStreamString(stream)}`,
95 | );
96 | }
97 |
98 | private setLoaderDestroyTimeout(item: P2PLoaderContainerItem) {
99 | item.destroyTimeoutId = window.setTimeout(
100 | () => this.destroyAndRemoveLoader(item),
101 | this.config.p2pInactiveLoaderDestroyTimeoutMs,
102 | );
103 | }
104 |
105 | private destroyAndRemoveLoader(item: P2PLoaderContainerItem) {
106 | item.loader.destroy();
107 | this.loaders.delete(item.stream.runtimeId);
108 | this.logger(`destroy p2p loader: `, item.loggerInfo);
109 | }
110 |
111 | get currentLoader() {
112 | return this._currentLoaderItem.loader;
113 | }
114 |
115 | destroy() {
116 | for (const { loader, destroyTimeoutId } of this.loaders.values()) {
117 | loader.destroy();
118 | clearTimeout(destroyTimeoutId);
119 | }
120 | this.loaders.clear();
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/p2p/peer-protocol.ts:
--------------------------------------------------------------------------------
1 | import { PeerConnection } from "bittorrent-tracker";
2 | import { CoreEventMap, StreamConfig } from "../types.js";
3 | import * as Utils from "../utils/utils.js";
4 | import * as Command from "./commands/index.js";
5 | import { EventTarget } from "../utils/event-target.js";
6 |
7 | export type PeerConfig = Pick<
8 | StreamConfig,
9 | | "p2pNotReceivingBytesTimeoutMs"
10 | | "webRtcMaxMessageSize"
11 | | "p2pErrorRetries"
12 | | "validateP2PSegment"
13 | >;
14 |
15 | export class PeerProtocol {
16 | private commandChunks?: Command.BinaryCommandChunksJoiner;
17 | private uploadingContext?: { stopUploading: () => void; requestId: number };
18 | private readonly onChunkDownloaded: CoreEventMap["onChunkDownloaded"];
19 | private readonly onChunkUploaded: CoreEventMap["onChunkUploaded"];
20 |
21 | constructor(
22 | private readonly connection: PeerConnection,
23 | private readonly peerConfig: PeerConfig,
24 | private readonly eventHandlers: {
25 | onCommandReceived: (command: Command.PeerCommand) => void;
26 | onSegmentChunkReceived: (data: Uint8Array) => void;
27 | },
28 | eventTarget: EventTarget,
29 | ) {
30 | this.onChunkDownloaded =
31 | eventTarget.getEventDispatcher("onChunkDownloaded");
32 | this.onChunkUploaded = eventTarget.getEventDispatcher("onChunkUploaded");
33 | connection.on("data", this.onDataReceived);
34 | }
35 |
36 | private onDataReceived = (data: Uint8Array) => {
37 | if (Command.isCommandChunk(data)) {
38 | this.receivingCommandBytes(data);
39 | } else {
40 | this.eventHandlers.onSegmentChunkReceived(data);
41 |
42 | this.onChunkDownloaded(data.byteLength, "p2p", this.connection.idUtf8);
43 | }
44 | };
45 |
46 | sendCommand(command: Command.PeerCommand) {
47 | const binaryCommandBuffers = Command.serializePeerCommand(
48 | command,
49 | this.peerConfig.webRtcMaxMessageSize,
50 | );
51 | for (const buffer of binaryCommandBuffers) {
52 | this.connection.write(buffer);
53 | }
54 | }
55 |
56 | stopUploadingSegmentData() {
57 | this.uploadingContext?.stopUploading();
58 | this.uploadingContext = undefined;
59 | }
60 |
61 | getUploadingRequestId() {
62 | return this.uploadingContext?.requestId;
63 | }
64 |
65 | async splitSegmentDataToChunksAndUploadAsync(
66 | data: Uint8Array,
67 | requestId: number,
68 | ) {
69 | if (this.uploadingContext) {
70 | throw new Error(`Some segment data is already uploading.`);
71 | }
72 | const chunks = getBufferChunks(data, this.peerConfig.webRtcMaxMessageSize);
73 | const { promise, resolve, reject } = Utils.getControlledPromise();
74 |
75 | let isUploadingSegmentData = false;
76 |
77 | const uploadingContext = {
78 | stopUploading: () => {
79 | isUploadingSegmentData = false;
80 | },
81 | requestId,
82 | };
83 |
84 | this.uploadingContext = uploadingContext;
85 |
86 | const sendChunk = () => {
87 | if (!isUploadingSegmentData) {
88 | reject();
89 | return;
90 | }
91 |
92 | while (true) {
93 | const chunk = chunks.next().value;
94 |
95 | if (!chunk) {
96 | resolve();
97 | break;
98 | }
99 |
100 | const drained = this.connection.write(chunk);
101 | this.onChunkUploaded(chunk.byteLength, this.connection.idUtf8);
102 | if (!drained) break;
103 | }
104 | };
105 |
106 | try {
107 | this.connection.on("drain", sendChunk);
108 | isUploadingSegmentData = true;
109 | sendChunk();
110 | await promise;
111 | } finally {
112 | this.connection.off("drain", sendChunk);
113 |
114 | if (this.uploadingContext === uploadingContext) {
115 | this.uploadingContext = undefined;
116 | }
117 | }
118 | }
119 |
120 | private receivingCommandBytes(buffer: Uint8Array) {
121 | if (!this.commandChunks) {
122 | this.commandChunks = new Command.BinaryCommandChunksJoiner(
123 | (commandBuffer) => {
124 | this.commandChunks = undefined;
125 | const command = Command.deserializeCommand(commandBuffer);
126 | this.eventHandlers.onCommandReceived(command);
127 | },
128 | );
129 | }
130 | try {
131 | this.commandChunks.addCommandChunk(buffer);
132 | } catch (err) {
133 | if (!(err instanceof Command.BinaryCommandJoiningError)) return;
134 | this.commandChunks = undefined;
135 | }
136 | }
137 | }
138 |
139 | function* getBufferChunks(
140 | data: ArrayBuffer,
141 | maxChunkSize: number,
142 | ): Generator {
143 | let bytesLeft = data.byteLength;
144 | while (bytesLeft > 0) {
145 | const bytesToSend = bytesLeft >= maxChunkSize ? maxChunkSize : bytesLeft;
146 | const from = data.byteLength - bytesLeft;
147 | const buffer = data.slice(from, from + bytesToSend);
148 | bytesLeft -= bytesToSend;
149 | yield buffer;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/requests/engine-request.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CoreRequestError,
3 | EngineCallbacks,
4 | SegmentWithStream,
5 | } from "../types.js";
6 |
7 | export class EngineRequest {
8 | private _status: "pending" | "succeed" | "failed" | "aborted" = "pending";
9 | private _shouldBeStartedImmediately = false;
10 |
11 | constructor(
12 | readonly segment: SegmentWithStream,
13 | readonly engineCallbacks: EngineCallbacks,
14 | ) {}
15 |
16 | get status() {
17 | return this._status;
18 | }
19 |
20 | get shouldBeStartedImmediately() {
21 | return this._shouldBeStartedImmediately;
22 | }
23 |
24 | resolve(data: ArrayBuffer, bandwidth: number) {
25 | if (this._status !== "pending") return;
26 | this._status = "succeed";
27 | this.engineCallbacks.onSuccess({ data, bandwidth });
28 | }
29 |
30 | reject() {
31 | if (this._status !== "pending") return;
32 | this._status = "failed";
33 | this.engineCallbacks.onError(new CoreRequestError("failed"));
34 | }
35 |
36 | abort() {
37 | if (this._status !== "pending") return;
38 | this._status = "aborted";
39 | this.engineCallbacks.onError(new CoreRequestError("aborted"));
40 | }
41 |
42 | markAsShouldBeStartedImmediately() {
43 | this._shouldBeStartedImmediately = true;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/requests/request-container.ts:
--------------------------------------------------------------------------------
1 | import { Playback, BandwidthCalculators } from "../internal-types.js";
2 | import { CoreEventMap, SegmentWithStream, StreamConfig } from "../types.js";
3 | import { EventTarget } from "../utils/event-target.js";
4 | import { Request } from "./request.js";
5 |
6 | export class RequestsContainer {
7 | private readonly requests = new Map();
8 |
9 | constructor(
10 | private readonly requestProcessQueueCallback: () => void,
11 | private readonly bandwidthCalculators: BandwidthCalculators,
12 | private readonly playback: Playback,
13 | private readonly config: StreamConfig,
14 | private readonly eventTarget: EventTarget,
15 | ) {}
16 |
17 | get executingHttpCount() {
18 | let count = 0;
19 | for (const request of this.httpRequests()) {
20 | if (request.status === "loading") count++;
21 | }
22 | return count;
23 | }
24 |
25 | get executingP2PCount() {
26 | let count = 0;
27 | for (const request of this.p2pRequests()) {
28 | if (request.status === "loading") count++;
29 | }
30 | return count;
31 | }
32 |
33 | get(segment: SegmentWithStream) {
34 | return this.requests.get(segment);
35 | }
36 |
37 | getOrCreateRequest(segment: SegmentWithStream) {
38 | let request = this.requests.get(segment);
39 | if (!request) {
40 | request = new Request(
41 | segment,
42 | this.requestProcessQueueCallback,
43 | this.bandwidthCalculators,
44 | this.playback,
45 | this.config,
46 | this.eventTarget,
47 | );
48 | this.requests.set(segment, request);
49 | }
50 | return request;
51 | }
52 |
53 | remove(request: Request) {
54 | this.requests.delete(request.segment);
55 | }
56 |
57 | items() {
58 | return this.requests.values();
59 | }
60 |
61 | *httpRequests(): Generator {
62 | for (const request of this.requests.values()) {
63 | if (request.downloadSource === "http") yield request;
64 | }
65 | }
66 |
67 | *p2pRequests(): Generator {
68 | for (const request of this.requests.values()) {
69 | if (request.downloadSource === "p2p") yield request;
70 | }
71 | }
72 |
73 | destroy() {
74 | for (const request of this.requests.values()) {
75 | if (request.status !== "loading") continue;
76 | request.abortFromProcessQueue();
77 | }
78 | this.requests.clear();
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/segment-storage/index.ts:
--------------------------------------------------------------------------------
1 | import { CommonCoreConfig, StreamConfig, StreamType } from "../types.js";
2 | /** Segments storage interface */
3 | export interface SegmentStorage {
4 | /**
5 | * Initializes storage
6 | * @param coreConfig - Core configuration with storage options
7 | * @param mainStreamConfig - Main stream configuration
8 | * @param secondaryStreamConfig - Secondary stream configuration
9 | */
10 | initialize(
11 | coreConfig: CommonCoreConfig,
12 | mainStreamConfig: StreamConfig,
13 | secondaryStreamConfig: StreamConfig,
14 | ): Promise;
15 |
16 | /**
17 | * Provides playback position from player
18 | * @param position - Playback position
19 | * @param rate - Playback rate
20 | */
21 | onPlaybackUpdated(position: number, rate: number): void;
22 |
23 | /**
24 | * Provides segment request information from player
25 | * @param swarmId - Swarm identifier
26 | * @param streamId - Stream identifier
27 | * @param segmentId - Segment identifier
28 | * @param startTime - Segment start time
29 | * @param endTime - Segment end time
30 | * @param streamType - Stream type
31 | * @param isLiveStream - Is live stream
32 | */
33 | onSegmentRequested(
34 | swarmId: string,
35 | streamId: string,
36 | segmentId: number,
37 | startTime: number,
38 | endTime: number,
39 | streamType: StreamType,
40 | isLiveStream: boolean,
41 | ): void;
42 |
43 | /**
44 | * Stores segment data
45 | * @param swarmId - Swarm identifier
46 | * @param streamId - Stream identifier
47 | * @param segmentId - Segment identifier
48 | * @param data - Segment data
49 | * @param startTime - Segment start time
50 | * @param endTime - Segment end time
51 | * @param streamType - Stream type
52 | * @param isLiveStream - Is live stream
53 | */
54 | storeSegment(
55 | swarmId: string,
56 | streamId: string,
57 | segmentId: number,
58 | data: ArrayBuffer,
59 | startTime: number,
60 | endTime: number,
61 | streamType: StreamType,
62 | isLiveStream: boolean,
63 | ): Promise;
64 |
65 | /**
66 | * Returns segment data
67 | * @param swarmId - Swarm identifier
68 | * @param streamId - Stream identifier
69 | * @param segmentId - Segment identifier
70 | */
71 | getSegmentData(
72 | swarmId: string,
73 | streamId: string,
74 | segmentId: number,
75 | ): Promise;
76 |
77 | /**
78 | * Returns used memory information in the storage
79 | */
80 | getUsage(): {
81 | totalCapacity: number;
82 | usedCapacity: number;
83 | };
84 |
85 | /**
86 | * Returns true if segment is in storage
87 | * @param swarmId - Swarm identifier
88 | * @param streamId - Stream identifier
89 | * @param segmentId - Segment identifier
90 | */
91 | hasSegment(swarmId: string, streamId: string, segmentId: number): boolean;
92 |
93 | /**
94 | * Returns segment IDs of a stream that are stored in the storage
95 | * @param swarmId - Swarm identifier
96 | * @param streamId - Stream identifier
97 | */
98 | getStoredSegmentIds(swarmId: string, streamId: string): number[];
99 |
100 | /**
101 | * Sets segment change callback function
102 | * @param callback - Callback function that has to be called when segments appear or disappear in the storage
103 | */
104 | setSegmentChangeCallback(callback: (streamId: string) => void): void;
105 |
106 | /**
107 | * Function to destroy storage
108 | */
109 | destroy(): void;
110 | }
111 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/segment-storage/utils.ts:
--------------------------------------------------------------------------------
1 | export const getStorageItemId = (streamId: string, segmentId: number) =>
2 | `${streamId}|${segmentId}`;
3 |
4 | export const isAndroid = (userAgent: string) => /Android/i.test(userAgent);
5 |
6 | export const isIPadOrIPhone = (userAgent: string) =>
7 | /iPad|iPhone/i.test(userAgent);
8 |
9 | export const isAndroidWebview = (userAgent: string) =>
10 | /Android/i.test(userAgent) && !/Chrome|Firefox/i.test(userAgent);
11 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/event-target.ts:
--------------------------------------------------------------------------------
1 | export class EventTarget<
2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
3 | EventTypesMap extends Record unknown>,
4 | > {
5 | private events = new Map<
6 | keyof EventTypesMap,
7 | EventTypesMap[keyof EventTypesMap][]
8 | >();
9 |
10 | public dispatchEvent(
11 | eventName: K,
12 | ...args: Parameters
13 | ) {
14 | const listeners = this.events.get(eventName);
15 | if (!listeners) return;
16 | for (const listener of listeners) {
17 | listener(...args);
18 | }
19 | }
20 |
21 | public getEventDispatcher(eventName: K) {
22 | let listeners = this.events.get(eventName);
23 | if (!listeners) {
24 | listeners = [];
25 | this.events.set(eventName, listeners);
26 | }
27 |
28 | const definedListeners = listeners;
29 |
30 | return (...args: Parameters) => {
31 | for (const listener of definedListeners) {
32 | listener(...args);
33 | }
34 | };
35 | }
36 |
37 | public addEventListener(
38 | eventName: K,
39 | listener: EventTypesMap[K],
40 | ) {
41 | const listeners = this.events.get(eventName);
42 | if (!listeners) {
43 | this.events.set(eventName, [listener]);
44 | } else {
45 | listeners.push(listener);
46 | }
47 | }
48 |
49 | public removeEventListener(
50 | eventName: K,
51 | listener: EventTypesMap[K],
52 | ) {
53 | const listeners = this.events.get(eventName);
54 | if (listeners) {
55 | const index = listeners.indexOf(listener);
56 | if (index !== -1) {
57 | listeners.splice(index, 1);
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import { SegmentWithStream, Stream } from "../types.js";
2 | import { SegmentPlaybackStatuses } from "./stream.js";
3 |
4 | export function getStreamString(stream: Stream) {
5 | return `${stream.type}-${stream.index}`;
6 | }
7 |
8 | export function getSegmentString(segment: SegmentWithStream) {
9 | const { externalId } = segment;
10 | return `(${getStreamString(segment.stream)} | ${externalId})`;
11 | }
12 |
13 | export function getSegmentPlaybackStatusesString(
14 | statuses: SegmentPlaybackStatuses,
15 | ): string {
16 | const { isHighDemand, isHttpDownloadable, isP2PDownloadable } = statuses;
17 | if (isHighDemand) return "high-demand";
18 | if (isHttpDownloadable && isP2PDownloadable) return "http-p2p-window";
19 | if (isHttpDownloadable) return "http-window";
20 | if (isP2PDownloadable) return "p2p-window";
21 | return "-";
22 | }
23 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/peer.ts:
--------------------------------------------------------------------------------
1 | import md5 from "nano-md5";
2 | import { PACKAGE_VERSION } from "./version.js";
3 |
4 | export const TRACKER_CLIENT_VERSION_PREFIX = `-PM${formatVersion(PACKAGE_VERSION)}-`;
5 |
6 | const HASH_SYMBOLS =
7 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
8 | const PEER_ID_LENGTH = 20;
9 |
10 | export function getStreamHash(streamId: string): string {
11 | // slice one byte to have 15 bytes binary string
12 | const binary15BytesHashString = md5.fromUtf8(streamId).slice(1);
13 | const base64Hash20CharsString = btoa(binary15BytesHashString);
14 | return base64Hash20CharsString;
15 | }
16 |
17 | export function generatePeerId(trackerClientVersionPrefix: string): string {
18 | const trackerClientId = [trackerClientVersionPrefix];
19 | const randomCharsCount = PEER_ID_LENGTH - trackerClientVersionPrefix.length;
20 |
21 | for (let i = 0; i < randomCharsCount; i++) {
22 | trackerClientId.push(
23 | HASH_SYMBOLS[Math.floor(Math.random() * HASH_SYMBOLS.length)],
24 | );
25 | }
26 |
27 | return trackerClientId.join("");
28 | }
29 |
30 | function formatVersion(versionString: string) {
31 | const splittedVersion = versionString.split(".");
32 |
33 | return `${splittedVersion[0].padStart(2, "0")}${splittedVersion[1].padStart(2, "0")}`;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/queue.ts:
--------------------------------------------------------------------------------
1 | import { Playback } from "../internal-types.js";
2 | import { P2PLoader } from "../p2p/loader.js";
3 | import { SegmentWithStream } from "../types.js";
4 | import {
5 | getSegmentPlaybackStatuses,
6 | SegmentPlaybackStatuses,
7 | PlaybackTimeWindowsConfig,
8 | } from "./stream.js";
9 |
10 | export type QueueItem = {
11 | segment: SegmentWithStream;
12 | statuses: SegmentPlaybackStatuses;
13 | };
14 |
15 | export function* generateQueue(
16 | lastRequestedSegment: Readonly,
17 | playback: Readonly,
18 | playbackConfig: PlaybackTimeWindowsConfig,
19 | currentP2PLoader: P2PLoader,
20 | availablePercentMemory: number,
21 | ): Generator {
22 | const { runtimeId, stream } = lastRequestedSegment;
23 |
24 | const requestedSegment = stream.segments.get(runtimeId);
25 | if (!requestedSegment) return;
26 |
27 | const queueSegments = stream.segments.values();
28 |
29 | let first: SegmentWithStream;
30 |
31 | do {
32 | const next = queueSegments.next();
33 | if (next.done) return; // should never happen
34 | first = next.value;
35 | } while (first !== requestedSegment);
36 |
37 | const firstStatuses = getSegmentPlaybackStatuses(
38 | first,
39 | playback,
40 | playbackConfig,
41 | currentP2PLoader,
42 | availablePercentMemory,
43 | );
44 | if (isNotActualStatuses(firstStatuses)) {
45 | const next = queueSegments.next();
46 |
47 | // for cases when engine requests segment that is a little bit
48 | // earlier than current playhead position
49 | // it could happen when playhead position is significantly changed by user
50 | if (next.done) return;
51 |
52 | const second = next.value;
53 |
54 | const secondStatuses = getSegmentPlaybackStatuses(
55 | second,
56 | playback,
57 | playbackConfig,
58 | currentP2PLoader,
59 | availablePercentMemory,
60 | );
61 |
62 | if (isNotActualStatuses(secondStatuses)) return;
63 | firstStatuses.isHighDemand = true;
64 | yield { segment: first, statuses: firstStatuses };
65 | yield { segment: second, statuses: secondStatuses };
66 | } else {
67 | yield { segment: first, statuses: firstStatuses };
68 | }
69 |
70 | for (const segment of queueSegments) {
71 | const statuses = getSegmentPlaybackStatuses(
72 | segment,
73 | playback,
74 | playbackConfig,
75 | currentP2PLoader,
76 | availablePercentMemory,
77 | );
78 | if (isNotActualStatuses(statuses)) break;
79 | yield { segment, statuses };
80 | }
81 | }
82 |
83 | function isNotActualStatuses(statuses: SegmentPlaybackStatuses) {
84 | const {
85 | isHighDemand = false,
86 | isHttpDownloadable = false,
87 | isP2PDownloadable = false,
88 | } = statuses;
89 | return !isHighDemand && !isHttpDownloadable && !isP2PDownloadable;
90 | }
91 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/stream.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SegmentWithStream,
3 | Stream,
4 | StreamConfig,
5 | StreamWithSegments,
6 | } from "../types.js";
7 | import { Playback } from "../internal-types.js";
8 | import { P2PLoader } from "../p2p/loader.js";
9 |
10 | export type SegmentPlaybackStatuses = {
11 | isHighDemand: boolean;
12 | isHttpDownloadable: boolean;
13 | isP2PDownloadable: boolean;
14 | };
15 |
16 | export type PlaybackTimeWindowsConfig = Pick<
17 | StreamConfig,
18 | "highDemandTimeWindow" | "httpDownloadTimeWindow" | "p2pDownloadTimeWindow"
19 | >;
20 |
21 | const PEER_PROTOCOL_VERSION = "v2";
22 |
23 | export function getStreamSwarmId(
24 | swarmId: string,
25 | stream: Readonly,
26 | ): string {
27 | return `${PEER_PROTOCOL_VERSION}-${swarmId}-${getStreamId(stream)}`;
28 | }
29 |
30 | export function getSegmentFromStreamsMap(
31 | streams: Map,
32 | segmentRuntimeId: string,
33 | ): SegmentWithStream | undefined {
34 | for (const stream of streams.values()) {
35 | const segment = stream.segments.get(segmentRuntimeId);
36 | if (segment) return segment;
37 | }
38 | }
39 |
40 | export function getSegmentFromStreamByExternalId(
41 | stream: StreamWithSegments,
42 | segmentExternalId: number,
43 | ): SegmentWithStream | undefined {
44 | for (const segment of stream.segments.values()) {
45 | if (segment.externalId === segmentExternalId) return segment;
46 | }
47 | }
48 |
49 | export function getStreamId(stream: Stream) {
50 | return `${stream.type}-${stream.index}`;
51 | }
52 |
53 | export function getSegmentAvgDuration(stream: StreamWithSegments) {
54 | const { segments } = stream;
55 | let sumDuration = 0;
56 | const { size } = segments;
57 | for (const segment of segments.values()) {
58 | const duration = segment.endTime - segment.startTime;
59 | sumDuration += duration;
60 | }
61 |
62 | return sumDuration / size;
63 | }
64 |
65 | function calculateTimeWindows(
66 | timeWindowsConfig: PlaybackTimeWindowsConfig,
67 | availableMemoryInPercent: number,
68 | ) {
69 | const {
70 | highDemandTimeWindow,
71 | httpDownloadTimeWindow,
72 | p2pDownloadTimeWindow,
73 | } = timeWindowsConfig;
74 |
75 | const result = {
76 | highDemandTimeWindow,
77 | httpDownloadTimeWindow,
78 | p2pDownloadTimeWindow,
79 | };
80 |
81 | if (availableMemoryInPercent <= 5) {
82 | result.httpDownloadTimeWindow = 0;
83 | result.p2pDownloadTimeWindow = 0;
84 | } else if (availableMemoryInPercent <= 10) {
85 | result.p2pDownloadTimeWindow = result.httpDownloadTimeWindow;
86 | }
87 |
88 | return result;
89 | }
90 |
91 | export function getSegmentPlaybackStatuses(
92 | segment: SegmentWithStream,
93 | playback: Playback,
94 | timeWindowsConfig: PlaybackTimeWindowsConfig,
95 | currentP2PLoader: P2PLoader,
96 | availableMemoryPercent: number,
97 | ): SegmentPlaybackStatuses {
98 | const {
99 | highDemandTimeWindow,
100 | httpDownloadTimeWindow,
101 | p2pDownloadTimeWindow,
102 | } = calculateTimeWindows(timeWindowsConfig, availableMemoryPercent);
103 |
104 | return {
105 | isHighDemand: isSegmentInTimeWindow(
106 | segment,
107 | playback,
108 | highDemandTimeWindow,
109 | ),
110 | isHttpDownloadable: isSegmentInTimeWindow(
111 | segment,
112 | playback,
113 | httpDownloadTimeWindow,
114 | ),
115 | isP2PDownloadable:
116 | isSegmentInTimeWindow(segment, playback, p2pDownloadTimeWindow) &&
117 | currentP2PLoader.isSegmentLoadingOrLoadedBySomeone(segment),
118 | };
119 | }
120 |
121 | function isSegmentInTimeWindow(
122 | segment: SegmentWithStream,
123 | playback: Playback,
124 | timeWindowLength: number,
125 | ) {
126 | const { startTime, endTime } = segment;
127 | const { position, rate } = playback;
128 | const rightMargin = position + timeWindowLength * rate;
129 | return !(rightMargin < startTime || position > endTime);
130 | }
131 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { CommonCoreConfig, CoreConfig, StreamConfig } from "../types.js";
2 |
3 | export function getControlledPromise() {
4 | let resolve: (value: T) => void;
5 | let reject: (reason?: unknown) => void;
6 | const promise = new Promise((res, rej) => {
7 | resolve = res;
8 | reject = rej;
9 | });
10 |
11 | return {
12 | promise,
13 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
14 | resolve: resolve!,
15 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
16 | reject: reject!,
17 | };
18 | }
19 |
20 | export function joinChunks(
21 | chunks: Uint8Array[],
22 | totalBytes?: number,
23 | ): Uint8Array {
24 | if (totalBytes === undefined) {
25 | totalBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
26 | }
27 | const buffer = new Uint8Array(totalBytes);
28 | let offset = 0;
29 | for (const chunk of chunks) {
30 | buffer.set(chunk, offset);
31 | offset += chunk.byteLength;
32 | }
33 |
34 | return buffer;
35 | }
36 |
37 | export function getPercent(numerator: number, denominator: number): number {
38 | return (numerator / denominator) * 100;
39 | }
40 |
41 | export function getRandomItem(items: T[]): T {
42 | return items[Math.floor(Math.random() * items.length)];
43 | }
44 |
45 | export function utf8ToUintArray(utf8String: string): Uint8Array {
46 | const encoder = new TextEncoder();
47 | const bytes = new Uint8Array(utf8String.length);
48 | encoder.encodeInto(utf8String, bytes);
49 | return bytes;
50 | }
51 |
52 | export function hexToUtf8(hexString: string) {
53 | const bytes = new Uint8Array(hexString.length / 2);
54 |
55 | for (let i = 0; i < hexString.length; i += 2) {
56 | bytes[i / 2] = parseInt(hexString.slice(i, i + 2), 16);
57 | }
58 | const decoder = new TextDecoder();
59 | return decoder.decode(bytes);
60 | }
61 |
62 | export function* arrayBackwards(arr: T[]) {
63 | for (let i = arr.length - 1; i >= 0; i--) {
64 | yield arr[i];
65 | }
66 | }
67 |
68 | function isObject(item: unknown): item is Record {
69 | return !!item && typeof item === "object" && !Array.isArray(item);
70 | }
71 |
72 | function isArray(item: unknown): item is unknown[] {
73 | return Array.isArray(item);
74 | }
75 |
76 | export function filterUndefinedProps(obj: T): Partial {
77 | function filter(obj: unknown): unknown {
78 | if (isObject(obj)) {
79 | const result: Record = {};
80 | Object.keys(obj).forEach((key) => {
81 | if (obj[key] !== undefined) {
82 | const value = filter(obj[key]);
83 | if (value !== undefined) {
84 | result[key] = value;
85 | }
86 | }
87 | });
88 | return result;
89 | } else {
90 | return obj;
91 | }
92 | }
93 |
94 | return filter(obj) as Partial;
95 | }
96 |
97 | export function deepCopy(item: T): T {
98 | if (isArray(item)) {
99 | return item.map((element) => deepCopy(element)) as T;
100 | } else if (isObject(item)) {
101 | const copy = {} as Record;
102 | for (const key of Object.keys(item)) {
103 | copy[key] = deepCopy(item[key]);
104 | }
105 | return copy as T;
106 | } else {
107 | return item;
108 | }
109 | }
110 |
111 | export function shuffleArray(array: T[]): T[] {
112 | for (let i = array.length - 1; i > 0; i--) {
113 | const j = Math.floor(Math.random() * (i + 1));
114 | [array[i], array[j]] = [array[j], array[i]];
115 | }
116 | return array;
117 | }
118 |
119 | type RecursivePartial = {
120 | [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P];
121 | };
122 |
123 | export function overrideConfig(
124 | target: T,
125 | updates: RecursivePartial | null,
126 | defaults: RecursivePartial = {} as RecursivePartial,
127 | ): T {
128 | if (
129 | typeof target !== "object" ||
130 | target === null ||
131 | typeof updates !== "object" ||
132 | updates === null
133 | ) {
134 | return target;
135 | }
136 |
137 | (Object.keys(updates) as (keyof T)[]).forEach((key) => {
138 | if (key === "__proto__" || key === "constructor" || key === "prototype") {
139 | throw new Error(`Attempt to modify restricted property '${String(key)}'`);
140 | }
141 |
142 | const updateValue = updates[key];
143 | const defaultValue = defaults[key];
144 |
145 | if (key in target) {
146 | if (updateValue === undefined) {
147 | target[key] =
148 | defaultValue === undefined
149 | ? (undefined as (T & object)[keyof T])
150 | : (defaultValue as (T & object)[keyof T]);
151 | } else {
152 | target[key] = updateValue as (T & object)[keyof T];
153 | }
154 | }
155 | });
156 |
157 | return target;
158 | }
159 |
160 | type MergeConfigsToTypeOptions = {
161 | defaultConfig: StreamConfig | CommonCoreConfig | CoreConfig;
162 | baseConfig?: Partial;
163 | specificStreamConfig?: Partial;
164 | };
165 |
166 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
167 | export function mergeAndFilterConfig(options: MergeConfigsToTypeOptions) {
168 | const { defaultConfig, baseConfig = {}, specificStreamConfig = {} } = options;
169 |
170 | const mergedConfig = deepCopy({
171 | ...defaultConfig,
172 | ...baseConfig,
173 | ...specificStreamConfig,
174 | });
175 |
176 | const keysOfT = Object.keys(defaultConfig) as (keyof T)[];
177 | const filteredConfig: Partial = {};
178 |
179 | keysOfT.forEach((key) => {
180 | if (key in mergedConfig) {
181 | filteredConfig[key] = mergedConfig[
182 | key as keyof typeof mergedConfig
183 | ] as T[keyof T];
184 | }
185 | });
186 |
187 | return filteredConfig as T;
188 | }
189 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/src/utils/version.ts:
--------------------------------------------------------------------------------
1 | export const PACKAGE_VERSION = "2.1.0";
2 |
3 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "lib",
5 | "rootDir": "src",
6 | "tsBuildInfoFile": "./build/.tsbuildinfo"
7 | },
8 | "include": ["src/**/*"],
9 | "references": [{ "path": "./tsconfig.node.json" }]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler"
6 | },
7 | "include": ["vite.config.ts", "eslint.config.js"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "entryPoints": ["src/index.ts"],
3 | "excludeExternals": true,
4 | "excludePrivate": true,
5 | "readme": "none",
6 | "sort": ["source-order"]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-core/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import type { UserConfig } from "vite";
3 | import { nodePolyfills } from "vite-plugin-node-polyfills";
4 | import terser from "@rollup/plugin-terser";
5 |
6 | const getESMConfig = ({ minify }: { minify: boolean }): UserConfig => {
7 | return {
8 | build: {
9 | emptyOutDir: false,
10 | minify: minify ? "esbuild" : false,
11 | sourcemap: true,
12 | lib: {
13 | name: "p2pml.core",
14 | fileName: (format) =>
15 | `p2p-media-loader-core.${format}${minify ? ".min" : ""}.js`,
16 | formats: ["es"],
17 | entry: "src/index.ts",
18 | },
19 | },
20 | plugins: [
21 | nodePolyfills(),
22 | minify
23 | ? terser({
24 | format: {
25 | comments: false,
26 | },
27 | })
28 | : undefined,
29 | ],
30 | };
31 | };
32 |
33 | export default defineConfig(({ mode }) => {
34 | switch (mode) {
35 | case "esm":
36 | return getESMConfig({ minify: false });
37 |
38 | case "esm-min":
39 | default:
40 | return getESMConfig({ minify: true });
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/README.md:
--------------------------------------------------------------------------------
1 | ../../README.md
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/dummy.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novage/p2p-media-loader/aa017813f8658033fb0e241ed7b2d2c25f31ccb3/packages/p2p-media-loader-demo/dummy.ts
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import tsEslint from "typescript-eslint";
4 | import { CommonReactConfig } from "../../eslint.common.react.config.js";
5 |
6 | export default tsEslint.config(...CommonReactConfig);
7 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-media-loader-demo",
3 | "description": "P2P Media Loader demo",
4 | "license": "Apache-2.0",
5 | "author": "Novage",
6 | "homepage": "https://github.com/Novage/p2p-media-loader",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Novage/p2p-media-loader",
10 | "directory": "packages/p2p-media-loader-demo"
11 | },
12 | "keywords": [
13 | "p2p",
14 | "peer-to-peer",
15 | "hls",
16 | "dash",
17 | "webrtc",
18 | "video",
19 | "mse",
20 | "player",
21 | "torrent",
22 | "bittorrent",
23 | "webtorrent",
24 | "hlsjs",
25 | "shaka player",
26 | "ecdn",
27 | "cdn"
28 | ],
29 | "version": "2.1.0",
30 | "type": "module",
31 | "files": [
32 | "lib",
33 | "src"
34 | ],
35 | "exports": "./src/index.ts",
36 | "types": "./src/index.ts",
37 | "publishConfig": {
38 | "exports": "./lib/index.js",
39 | "types": "./lib/index.d.ts"
40 | },
41 | "scripts": {
42 | "build": "rimraf lib build && tsc && pnpm copy-css",
43 | "copy-css": "cpy \"src/**/*.css\" \"./lib/\" --parents",
44 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
45 | "type-check": "tsc --noEmit",
46 | "clean": "rimraf lib dist build p2p-media-loader-demo-*.tgz",
47 | "clean-with-modules": "rimraf node_modules && pnpm clean"
48 | },
49 | "dependencies": {
50 | "@vidstack/react": "^1.12.13",
51 | "d3": "^7.9.0",
52 | "dplayer": "^1.27.1",
53 | "hls.js": "^1.5.20",
54 | "mediaelement": "^7.0.7",
55 | "openplayerjs": "^2.14.8",
56 | "p2p-media-loader-core": "workspace:*",
57 | "p2p-media-loader-hlsjs": "workspace:*",
58 | "p2p-media-loader-shaka": "workspace:*",
59 | "plyr": "^3.7.8",
60 | "shaka-player": "^4.13.8"
61 | },
62 | "devDependencies": {
63 | "@types/d3": "^7.4.3",
64 | "@types/dplayer": "^1.25.5",
65 | "@types/react": "^19.0.10",
66 | "@types/react-dom": "^19.0.4",
67 | "cpy-cli": "^5.0.0",
68 | "eslint-plugin-react": "^7.37.4",
69 | "eslint-plugin-react-hooks": "^5.2.0",
70 | "eslint-plugin-react-refresh": "^0.4.19",
71 | "react": "^19.0.0",
72 | "react-dom": "^19.0.0"
73 | },
74 | "peerDependencies": {
75 | "react": ">=16",
76 | "react-dom": ">=16"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/PlaybackOptions.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 | import { PLAYERS } from "../constants";
3 | import { PlayerKey, PlayerName } from "../types";
4 |
5 | type PlaybackOptions = {
6 | updatePlaybackOptions: (url: string, player: string) => void;
7 | currentPlayer: string;
8 | streamUrl: string;
9 | };
10 |
11 | export const PlaybackOptions = ({
12 | updatePlaybackOptions,
13 | currentPlayer,
14 | streamUrl,
15 | }: PlaybackOptions) => {
16 | const playerSelectRef = useRef(null);
17 | const streamUrlInputRef = useRef(null);
18 |
19 | const isHttps = window.location.protocol === "https:";
20 |
21 | const hlsPlayers: Partial> = {};
22 | const shakaPlayers: Partial> = {};
23 |
24 | Object.entries(PLAYERS).forEach(([key, name]) => {
25 | if (key.includes("hls")) {
26 | hlsPlayers[key as PlayerKey] = name;
27 | } else if (key.includes("shaka")) {
28 | shakaPlayers[key as PlayerKey] = name;
29 | }
30 | });
31 |
32 | const handleApply = () => {
33 | const player = playerSelectRef.current?.value;
34 | const streamUrl = streamUrlInputRef.current?.value;
35 |
36 | if (player && streamUrl) {
37 | updatePlaybackOptions(streamUrl, player);
38 | }
39 | };
40 |
41 | return (
42 |
43 |
44 |
47 |
54 |
55 |
56 |
57 |
58 |
82 |
83 |
84 |
85 |
88 |
96 |
97 |
98 | );
99 | };
100 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/chart/ChartLegend.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 |
3 | type LegendItem = {
4 | color: string;
5 | content: string | ReactElement;
6 | };
7 |
8 | type ChartLegendProps = {
9 | legendItems: LegendItem[];
10 | };
11 |
12 | export const ChartLegend = ({ legendItems }: ChartLegendProps) => {
13 | return (
14 |
15 | {legendItems.map((item, index) => (
16 | // eslint-disable-next-line @eslint-react/no-array-index-key
17 |
18 |
19 |
{item.content}
20 |
21 | ))}
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/chart/DownloadStatsChart.tsx:
--------------------------------------------------------------------------------
1 | import "./chart.css";
2 | import { useEffect, useRef, useState } from "react";
3 | import { DownloadStats, ChartsData, SvgDimensionsType } from "../../types";
4 | import { COLORS } from "../../constants";
5 | import { ChartLegend } from "./ChartLegend";
6 | import { drawChart } from "./drawChart";
7 |
8 | const generateInitialStackedData = () => {
9 | const nowInSeconds = Math.floor(performance.now() / 1000);
10 | return Array.from({ length: 120 }, (_, i) => ({
11 | seconds: nowInSeconds - (120 - i),
12 | httpDownloaded: 0,
13 | p2pDownloaded: 0,
14 | p2pUploaded: 0,
15 | }));
16 | };
17 |
18 | const convertToMiB = (bytes: number) => bytes / 1024 / 1024;
19 | const convertToMbit = (bytes: number) => (bytes * 8) / 1_000_000;
20 |
21 | const calculatePercentage = (part: number, total: number) => {
22 | if (total === 0) {
23 | return 0;
24 | }
25 | return ((part / total) * 100).toFixed(2);
26 | };
27 |
28 | type StatsChartProps = {
29 | downloadStatsRef: React.RefObject;
30 | };
31 |
32 | type StoredData = {
33 | totalDownloaded: number;
34 | } & DownloadStats;
35 |
36 | export const DownloadStatsChart = ({ downloadStatsRef }: StatsChartProps) => {
37 | const [data, setData] = useState(() =>
38 | generateInitialStackedData(),
39 | );
40 |
41 | const [storedData, setStoredData] = useState({
42 | totalDownloaded: 0,
43 | httpDownloaded: 0,
44 | p2pDownloaded: 0,
45 | p2pUploaded: 0,
46 | });
47 |
48 | const [svgDimensions, setSvgDimensions] = useState({
49 | width: 0,
50 | height: 0,
51 | });
52 |
53 | const svgRef = useRef(null);
54 | const svgContainerRef = useRef(null);
55 |
56 | useEffect(() => {
57 | const intervalID = setInterval(() => {
58 | const { httpDownloaded, p2pDownloaded, p2pUploaded } =
59 | downloadStatsRef.current;
60 |
61 | setData((prevData: ChartsData[]) => {
62 | const newData: ChartsData = {
63 | seconds: Math.floor(performance.now() / 1000),
64 | httpDownloaded: convertToMbit(httpDownloaded),
65 | p2pDownloaded: convertToMbit(p2pDownloaded),
66 | p2pUploaded: convertToMbit(p2pUploaded * -1),
67 | };
68 | return [...prevData.slice(1), newData];
69 | });
70 |
71 | setStoredData((prevData) => ({
72 | totalDownloaded:
73 | prevData.totalDownloaded +
74 | convertToMiB(httpDownloaded + p2pDownloaded),
75 | httpDownloaded: prevData.httpDownloaded + convertToMiB(httpDownloaded),
76 | p2pDownloaded: prevData.p2pDownloaded + convertToMiB(p2pDownloaded),
77 | p2pUploaded: prevData.p2pUploaded + convertToMiB(p2pUploaded),
78 | }));
79 |
80 | downloadStatsRef.current.httpDownloaded = 0;
81 | downloadStatsRef.current.p2pDownloaded = 0;
82 | downloadStatsRef.current.p2pUploaded = 0;
83 | }, 1000);
84 |
85 | return () => {
86 | clearInterval(intervalID);
87 | };
88 | }, [downloadStatsRef]);
89 |
90 | useEffect(() => {
91 | const handleChartResize = (entries: ResizeObserverEntry[]) => {
92 | const entry = entries[0];
93 |
94 | const newDimensions = {
95 | width: entry.contentRect.width,
96 | height: 310,
97 | };
98 |
99 | setSvgDimensions(newDimensions);
100 | };
101 |
102 | const resizeObserver = new ResizeObserver(handleChartResize);
103 |
104 | if (svgContainerRef.current) {
105 | resizeObserver.observe(svgContainerRef.current);
106 | }
107 |
108 | return () => resizeObserver.disconnect();
109 | }, []);
110 |
111 | useEffect(() => {
112 | if (!svgRef.current) return;
113 |
114 | const svg = drawChart(svgRef.current, data);
115 |
116 | return () => {
117 | svg.selectAll("*").remove();
118 | };
119 | }, [data, svgDimensions]);
120 |
121 | return (
122 |
123 |
124 |
144 |
164 |
165 |
170 |
171 | );
172 | };
173 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/chart/chart.css:
--------------------------------------------------------------------------------
1 | .legend-container {
2 | margin-top: 10px;
3 | position: absolute;
4 | }
5 |
6 | .chart-legend {
7 | position: relative;
8 | margin-top: 30px;
9 | margin-left: 40px;
10 | height: 70px;
11 | font-family: Arial;
12 | font-size: 12px;
13 | color: #fff;
14 | background: #404040;
15 | display: flex;
16 | flex-direction: column;
17 | padding: 12px 5px;
18 | border-radius: 2px;
19 | opacity: 0.8;
20 | }
21 |
22 | .chart-legend p {
23 | margin: 0;
24 | color: #fff;
25 | }
26 |
27 | .chart-legend p::before {
28 | content: "";
29 | display: inline-block;
30 | width: 4px;
31 | }
32 |
33 | .chart-legend .line {
34 | margin-right: 3px;
35 | margin-left: 4px;
36 | border-radius: 2px;
37 | clear: both;
38 | line-height: 140%;
39 | padding-right: 15px;
40 | display: flex;
41 | align-items: center;
42 | }
43 |
44 | .chart-legend .swatch {
45 | display: inline-block;
46 | width: 8px;
47 | height: 8px;
48 | border: 1px solid rgba(0, 0, 0, 0.2);
49 | }
50 |
51 | .chart-legend .line .swatch {
52 | display: inline-block;
53 | margin-right: 3px;
54 | border-radius: 2px;
55 | }
56 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/chart/drawChart.ts:
--------------------------------------------------------------------------------
1 | import { COLORS } from "../../constants";
2 | import { ChartsData } from "./../../types";
3 | import * as d3 from "d3";
4 |
5 | export const drawChart = (svgElement: SVGSVGElement, data: ChartsData[]) => {
6 | const margin = { top: 20, right: 1, bottom: 30, left: 25 },
7 | width = svgElement.clientWidth - margin.left - margin.right,
8 | height = svgElement.clientHeight - margin.top - margin.bottom;
9 |
10 | const svg = d3.select(svgElement);
11 | svg
12 | .selectAll("g")
13 | .data([null])
14 | .enter()
15 | .append("g")
16 | .attr("transform", `translate(${margin.left},${margin.top})`);
17 |
18 | const downloadStatsStack = d3
19 | .stack()
20 | .keys(["httpDownloaded", "p2pDownloaded"])(data);
21 |
22 | const maxDownloadValue = d3.max(downloadStatsStack, (stack) =>
23 | d3.max(stack, (d) => d[1]),
24 | );
25 | const maxP2PUploadValue = d3.min(data, (d) => d.p2pUploaded);
26 |
27 | const defaultDomain = [0, 1];
28 | const extentDomain = d3.extent(data, (d) => d.seconds);
29 |
30 | const xScale = d3
31 | .scaleLinear()
32 | .domain(
33 | extentDomain.every((extent) => extent !== undefined)
34 | ? extentDomain
35 | : defaultDomain,
36 | )
37 | .range([0, width]);
38 | const yScale = d3
39 | .scaleLinear()
40 | .domain([maxP2PUploadValue ?? 0, maxDownloadValue ?? 1])
41 | .range([height, 0]);
42 |
43 | const color = d3
44 | .scaleOrdinal()
45 | .domain(["httpDownloaded", "p2pDownloaded"])
46 | .range([COLORS.yellow, COLORS.lightOrange]);
47 |
48 | const downloadStatsArea = d3
49 | .area>()
50 | .x((d) => xScale(d.data.seconds))
51 | .y0((d) => yScale(d[0]))
52 | .y1((d) => yScale(d[1]))
53 | .curve(d3.curveBasis);
54 | const downloadStatsAreaLine = d3
55 | .line>()
56 | .x((d) => xScale(d.data.seconds))
57 | .y((d) => yScale(d[1]))
58 | .curve(d3.curveBasis);
59 |
60 | const p2pUploadArea = d3
61 | .area()
62 | .x((d) => xScale(d.seconds))
63 | .y0(() => yScale(0))
64 | .y1((d) => yScale(d.p2pUploaded))
65 | .curve(d3.curveBasis);
66 | const p2pUploadLineArea = d3
67 | .line()
68 | .x((d) => xScale(d.seconds))
69 | .y((d) => yScale(d.p2pUploaded))
70 | .curve(d3.curveBasis);
71 |
72 | const content = svg.select("g");
73 | content.selectAll(".axis").remove();
74 |
75 | // Axes
76 | content
77 | .append("g")
78 | .attr("class", "axis axis--x")
79 | .attr("transform", `translate(0,${height})`)
80 | .call(
81 | d3
82 | .axisBottom(xScale)
83 | .tickSize(-height)
84 | .tickFormat(() => ""),
85 | )
86 | .selectAll("line")
87 | .attr("stroke", "#ddd")
88 | .attr("stroke-dasharray", "2,2");
89 | content
90 | .append("g")
91 | .attr("class", "axis axis--y")
92 | .call(d3.axisLeft(yScale).tickSize(-width).ticks(5))
93 | .selectAll("line")
94 | .attr("stroke", "#ddd")
95 | .attr("stroke-dasharray", "2,2");
96 |
97 | // Data layers
98 | content.selectAll(".layer").remove();
99 | content
100 | .selectAll(".p2p-upload-area")
101 | .data([data])
102 | .join("path")
103 | .attr("class", "p2p-upload-area")
104 | .attr("d", p2pUploadArea)
105 | .style("fill", COLORS.lightBlue)
106 | .style("opacity", 0.7);
107 | content
108 | .selectAll(".p2p-upload-line")
109 | .data([data])
110 | .join("path")
111 | .attr("class", "p2p-upload-line")
112 | .attr("d", p2pUploadLineArea)
113 | .style("fill", "none")
114 | .style("stroke", "blue")
115 | .style("stroke-width", 2);
116 |
117 | content
118 | .selectAll(".layer")
119 | .data(downloadStatsStack)
120 | .enter()
121 | .append("path")
122 | .attr("class", "layer")
123 | .attr("d", downloadStatsArea)
124 | .style("fill", (_d, i) => color(i.toString()) as string)
125 | .style("opacity", 0.7);
126 | content
127 | .selectAll(".line")
128 | .data(downloadStatsStack)
129 | .join("path")
130 | .attr("class", "line")
131 | .attr("d", downloadStatsAreaLine)
132 | .style("fill", "none")
133 | .style("stroke", (_d, i) => color(i.toString()) as string)
134 | .style("stroke-width", 1.5);
135 |
136 | return svg;
137 | };
138 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/debugTools/DebugSelector.tsx:
--------------------------------------------------------------------------------
1 | import { debug } from "p2p-media-loader-core";
2 | import { useCallback, useEffect, useState } from "react";
3 |
4 | export const DebugSelector = () => {
5 | const [activeLoggers, setActiveLoggers] = useLocalStorageItem(
6 | "debug",
7 | [],
8 | loggersToStorageItem,
9 | storageItemToLoggers,
10 | );
11 |
12 | const onChange = (event: React.ChangeEvent) => {
13 | setActiveLoggers(
14 | Array.from(event.target.selectedOptions, (option) => option.value),
15 | );
16 | };
17 |
18 | return (
19 |
20 |
Loggers:
21 |
33 |
34 | );
35 | };
36 |
37 | function useLocalStorageItem(
38 | prop: string,
39 | initValue: T,
40 | valueToStorageItem: (value: T) => string | null,
41 | storageItemToValue: (storageItem: string | null) => T,
42 | ): [T, React.Dispatch>] {
43 | const [value, setValue] = useState(
44 | () => storageItemToValue(localStorage[prop] as string | null) ?? initValue,
45 | );
46 | const setValueExternal = useCallback(
47 | (value: T | ((prev: T) => T)) => {
48 | setValue(value);
49 | if (typeof value === "function") {
50 | const prev = storageItemToValue(localStorage.getItem(prop));
51 | const next = (value as (prev: T) => T)(prev);
52 | const result = valueToStorageItem(next);
53 | if (result !== null) localStorage.setItem(prop, result);
54 | else localStorage.removeItem(prop);
55 | } else {
56 | const result = valueToStorageItem(value);
57 | if (result !== null) localStorage.setItem(prop, result);
58 | else localStorage.removeItem(prop);
59 | }
60 | },
61 | [prop, storageItemToValue, valueToStorageItem],
62 | );
63 |
64 | useEffect(() => {
65 | const eventHandler = (event: StorageEvent) => {
66 | if (event.key !== prop) return;
67 | const value = event.newValue;
68 | setValue(storageItemToValue(value));
69 | };
70 | window.addEventListener("storage", eventHandler);
71 | return () => {
72 | window.removeEventListener("storage", eventHandler);
73 | };
74 | }, [prop, storageItemToValue]);
75 |
76 | return [value, setValueExternal];
77 | }
78 |
79 | const loggers = [
80 | "p2pml-core:hybrid-loader-main",
81 | "p2pml-core:hybrid-loader-secondary",
82 | "p2pml-core:p2p-tracker-client",
83 | "p2pml-core:peer",
84 | "p2pml-core:p2p-loaders-container",
85 | "p2pml-core:request-main",
86 | "p2pml-core:request-secondary",
87 | "p2pml-core:segment-memory-storage",
88 | ] as const;
89 |
90 | const loggersToStorageItem = (list: string[]) => {
91 | setTimeout(() => debug.enable(localStorage.debug as string), 0);
92 | if (list.length === 0) return null;
93 | return list.join(",");
94 | };
95 |
96 | const storageItemToLoggers = (storageItem: string | null) => {
97 | setTimeout(() => debug.enable(localStorage.debug as string), 0);
98 | if (!storageItem) return [];
99 | return storageItem.split(",");
100 | };
101 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/debugTools/DebugTools.tsx:
--------------------------------------------------------------------------------
1 | import { DebugSelector } from "./DebugSelector";
2 |
3 | export const DebugTools = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/demo.css:
--------------------------------------------------------------------------------
1 | .demo-container {
2 | display: flex;
3 | flex-wrap: wrap;
4 | max-width: 100%;
5 | margin: 50px auto;
6 | padding: 0 15px;
7 | }
8 |
9 | .column-1,
10 | .column-2 {
11 | box-sizing: border-box;
12 | width: 100%;
13 | }
14 |
15 | @media (max-width: 575px) {
16 | .demo-container {
17 | min-width: 320px;
18 | }
19 | }
20 |
21 | @media (min-width: 576px) {
22 | .demo-container {
23 | max-width: 540px;
24 | }
25 | }
26 |
27 | @media (min-width: 768px) {
28 | .demo-container {
29 | max-width: 720px;
30 | flex-wrap: nowrap;
31 | }
32 | .column-1 {
33 | width: 66.66666667%;
34 | padding-right: 30px;
35 | }
36 | .column-2 {
37 | width: 33.33333333%;
38 | }
39 | }
40 |
41 | @media (min-width: 992px) {
42 | .demo-container {
43 | max-width: 960px;
44 | }
45 | }
46 |
47 | @media (min-width: 1200px) {
48 | .demo-container {
49 | max-width: 1140px;
50 | }
51 | }
52 |
53 | .playback-options {
54 | display: flex;
55 | flex-direction: column;
56 | width: 100%;
57 | }
58 |
59 | .option-group {
60 | display: flex;
61 | flex-direction: column;
62 | margin-bottom: 1em;
63 | }
64 |
65 | .option-group label {
66 | display: inline-block;
67 | margin-bottom: 0.5em;
68 | }
69 |
70 | .option-group .item {
71 | display: block;
72 | width: 100%;
73 | height: calc(2.25em + 2px);
74 | padding: 0.375rem 0.75rem;
75 | font-size: 1em;
76 | line-height: 1.5em;
77 | color: #495057;
78 | background-color: #fff;
79 | background-clip: padding-box;
80 | border: 1px solid #ced4da;
81 | border-radius: 0.25em;
82 | box-sizing: border-box;
83 | }
84 |
85 | .button-group {
86 | display: flex;
87 | }
88 |
89 | .playback-options button {
90 | margin-right: 5px;
91 | text-align: center;
92 | white-space: nowrap;
93 | border: 1px solid transparent;
94 | padding: 0.375em 0.75em;
95 | font-size: 1em;
96 | line-height: 1.5em;
97 | border-radius: 0.25em;
98 | color: #fff;
99 | background-color: #972e2d;
100 | border-color: #972e2d;
101 | margin-bottom: 5px;
102 | }
103 |
104 | .playback-options button:hover {
105 | background-color: #7a1f1e;
106 | border-color: #7a1f1e;
107 | cursor: pointer;
108 | }
109 |
110 | .node-network {
111 | margin: 3em auto;
112 | outline: 1px solid #eee;
113 | }
114 |
115 | .trackers-container {
116 | margin-top: 0;
117 | }
118 | .trackers-container span {
119 | display: block;
120 | font-size: 1.17em;
121 | margin-block-start: 1em;
122 | margin-block-end: 1em;
123 | margin-inline-start: 0px;
124 | margin-inline-end: 0px;
125 | font-weight: 700;
126 | margin: 0;
127 | padding: 0;
128 | }
129 |
130 | .trackers-list {
131 | list-style-type: none;
132 | padding: 0;
133 | margin: 0;
134 | }
135 |
136 | .trackers-list li {
137 | padding: 0;
138 | margin-top: 2px;
139 | }
140 | .video-container video {
141 | width: 100%;
142 | }
143 |
144 | .dev-container {
145 | margin-top: 0;
146 | display: flex;
147 | gap: 1em;
148 | }
149 |
150 | .error-message {
151 | margin-top: 1em;
152 | padding: 1em;
153 | background-color: #f8d7da;
154 | border: 1px solid #f5c6cb;
155 | border-radius: 0.25rem;
156 | color: #721c24;
157 | }
158 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/nodeNetwork/NodeNetwork.tsx:
--------------------------------------------------------------------------------
1 | import "./network.css";
2 | import { useEffect, useRef, useState } from "react";
3 | import * as d3 from "d3";
4 | import {
5 | Link,
6 | updateGraph,
7 | Node,
8 | prepareGroups,
9 | createSimulation,
10 | } from "./network";
11 | import { SvgDimensionsType } from "../../types";
12 |
13 | type GraphNetworkProps = {
14 | peers: string[];
15 | };
16 |
17 | const DEFAULT_PEER_ID = "You";
18 | const DEFAULT_NODE: Node = { id: DEFAULT_PEER_ID, isMain: true };
19 | const DEFAULT_GRAPH_DATA = {
20 | nodes: [DEFAULT_NODE],
21 | links: [] as Link[],
22 | };
23 |
24 | export const NodeNetwork = ({ peers }: GraphNetworkProps) => {
25 | const svgRef = useRef(null);
26 | const svgContainerRef = useRef(null);
27 | const networkDataRef = useRef(DEFAULT_GRAPH_DATA);
28 | const simulationRef = useRef | null>(null);
29 |
30 | const [svgDimensions, setSvgDimensions] = useState({
31 | width: 0,
32 | height: 0,
33 | });
34 |
35 | useEffect(() => {
36 | if (!svgRef.current) return;
37 |
38 | const handleResize = (entries: ResizeObserverEntry[]) => {
39 | const entry = entries[0];
40 |
41 | const newDimensions = {
42 | width: entry.contentRect.width,
43 | height: entry.contentRect.width > 380 ? 250 : 400,
44 | };
45 |
46 | setSvgDimensions(newDimensions);
47 |
48 | simulationRef.current?.stop();
49 | simulationRef.current = createSimulation(
50 | newDimensions.width,
51 | newDimensions.height,
52 | );
53 |
54 | updateGraph(
55 | networkDataRef.current.nodes,
56 | networkDataRef.current.links,
57 | simulationRef.current,
58 | svgRef.current,
59 | );
60 | };
61 |
62 | prepareGroups(svgRef.current);
63 |
64 | const resizeObserver = new ResizeObserver(handleResize);
65 |
66 | if (svgContainerRef.current) {
67 | resizeObserver.observe(svgContainerRef.current);
68 | }
69 |
70 | return () => {
71 | resizeObserver.disconnect();
72 | };
73 | }, []);
74 |
75 | useEffect(() => {
76 | const allNodes = [
77 | ...peers.map((peerId) => ({ id: peerId, isMain: false })),
78 | DEFAULT_NODE,
79 | ];
80 |
81 | const allLinks = peers.map((peerId) => {
82 | const target = allNodes.find((n) => n.id === peerId);
83 |
84 | if (!target) throw new Error("Target node not found");
85 |
86 | return {
87 | source: DEFAULT_NODE,
88 | target,
89 | linkId: `${DEFAULT_PEER_ID}-${peerId}`,
90 | };
91 | });
92 |
93 | const networkData = networkDataRef.current;
94 |
95 | const nodesToAdd = allNodes.filter(
96 | (an) => !networkData.nodes.find((n) => n.id === an.id),
97 | );
98 | const nodesToRemove = networkData.nodes.filter(
99 | (n) => !allNodes.find((an) => an.id === n.id),
100 | );
101 | const linksToAdd = allLinks.filter(
102 | (al) => !networkData.links.find((l) => l.linkId === al.linkId),
103 | );
104 | const linksToRemove = networkData.links.filter(
105 | (l) => !allLinks.find((al) => al.linkId === l.linkId),
106 | );
107 |
108 | const updatedNodes = networkData.nodes.filter(
109 | (n) => !nodesToRemove.find((rn) => rn.id === n.id),
110 | );
111 | const updatedLinks = networkData.links.filter(
112 | (l) => !linksToRemove.find((rl) => rl.linkId === l.linkId),
113 | );
114 |
115 | const newNetworkData = {
116 | nodes: [...updatedNodes, ...nodesToAdd],
117 | links: [...updatedLinks, ...linksToAdd],
118 | };
119 |
120 | networkDataRef.current = newNetworkData;
121 |
122 | updateGraph(
123 | newNetworkData.nodes,
124 | newNetworkData.links,
125 | simulationRef.current,
126 | svgRef.current,
127 | );
128 | }, [peers]);
129 |
130 | return (
131 |
132 |
138 |
139 | );
140 | };
141 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/nodeNetwork/network.css:
--------------------------------------------------------------------------------
1 | .node-container {
2 | position: relative;
3 | width: 100%;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/nodeNetwork/network.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from "d3";
2 |
3 | export interface Node extends d3.SimulationNodeDatum {
4 | id: string;
5 | isMain?: boolean;
6 | group?: number;
7 | }
8 |
9 | export interface Link extends d3.SimulationLinkDatum {
10 | source: Node;
11 | target: Node;
12 | linkId: string;
13 | }
14 |
15 | const COLORS = {
16 | links: "#C8C8C8",
17 | nodeHover: "#A9A9A9",
18 | node: (d: { isMain?: boolean }) => {
19 | return d.isMain ? "hsl(210, 70%, 72.5%)" : "hsl(55, 70%, 72.5%)";
20 | },
21 | };
22 |
23 | function handleNodeMouseOver(this: SVGCircleElement) {
24 | d3.select(this).style("fill", COLORS.nodeHover);
25 | }
26 |
27 | function handleNodeMouseOut(this: SVGCircleElement, _event: unknown, d: Node) {
28 | d3.select(this).style("fill", COLORS.node(d));
29 | }
30 |
31 | function getLinkText(d: Link) {
32 | return `${d.source.id}-${d.target.id}`;
33 | }
34 |
35 | function getNodeId(d: Node) {
36 | return d.id;
37 | }
38 |
39 | function removeD3Item(this: d3.BaseType) {
40 | d3.select(this).remove();
41 | }
42 |
43 | export const updateGraph = (
44 | newNodes: Node[],
45 | newLinks: Link[],
46 | simulation: d3.Simulation | null,
47 | svgElement: SVGSVGElement | null,
48 | ) => {
49 | if (!simulation || !svgElement) return;
50 |
51 | simulation.nodes(newNodes);
52 | simulation.force>("link")?.links(newLinks);
53 | simulation.alpha(0.5).restart();
54 |
55 | const link = d3
56 | .select(svgElement)
57 | .select(".links")
58 | .selectAll("line")
59 | .data(newLinks, getLinkText);
60 |
61 | link
62 | .enter()
63 | .append("line")
64 | .merge(link)
65 | .attr("stroke", COLORS.links)
66 | .transition()
67 | .duration(500)
68 | .attr("stroke-width", 0.5);
69 |
70 | link
71 | .exit()
72 | .transition()
73 | .duration(200)
74 | .style("opacity", 0)
75 | .on("end", removeD3Item);
76 |
77 | const node = d3
78 | .select(svgElement)
79 | .select(".nodes")
80 | .selectAll("circle")
81 | .data(newNodes, getNodeId);
82 |
83 | node
84 | .enter()
85 | .append("circle")
86 | .merge(node)
87 | .attr("r", (d) => (d.isMain ? 15 : 13))
88 | .attr("fill", (d) => COLORS.node(d))
89 | .on("mouseover", handleNodeMouseOver)
90 | .on("mouseout", handleNodeMouseOut)
91 | .call(drag(simulation));
92 |
93 | node.exit().transition().duration(200).attr("r", 0).remove();
94 |
95 | const text = d3
96 | .select(svgElement)
97 | .select(".nodes")
98 | .selectAll("text")
99 | .data(newNodes, getNodeId);
100 |
101 | text
102 | .enter()
103 | .append("text")
104 | .style("fill-opacity", 0)
105 | .merge(text)
106 | .text(getNodeId)
107 | .style("text-anchor", "middle")
108 | .style("font-size", "12px")
109 | .style("font-family", "sans-serif")
110 | .transition()
111 | .duration(500)
112 | .style("fill-opacity", 1);
113 |
114 | text
115 | .exit()
116 | .transition()
117 | .duration(200)
118 | .style("fill-opacity", 0)
119 | .on("end", removeD3Item);
120 |
121 | simulation.on("tick", () => {
122 | d3.select(svgElement)
123 | .select(".links")
124 | .selectAll("line")
125 | .attr("x1", (d) => d.source.x ?? 0)
126 | .attr("y1", (d) => d.source.y ?? 0)
127 | .attr("x2", (d) => d.target.x ?? 0)
128 | .attr("y2", (d) => d.target.y ?? 0);
129 |
130 | d3.select(svgElement)
131 | .select(".nodes")
132 | .selectAll("circle")
133 | .attr("cx", (d) => d.x ?? 0)
134 | .attr("cy", (d) => d.y ?? 0);
135 |
136 | d3.select(svgElement)
137 | .select(".nodes")
138 | .selectAll("text")
139 | .attr("x", (d) => d.x ?? 0)
140 | .attr("y", (d) => (d.y === undefined ? 0 : d.y - 20));
141 | });
142 | };
143 |
144 | const drag = (simulation: d3.Simulation) => {
145 | const dragStarted = (
146 | event: d3.D3DragEvent,
147 | d: Node,
148 | ) => {
149 | if (!event.active) simulation.alphaTarget(0.3).restart();
150 | d.fx = d.x;
151 | d.fy = d.y;
152 | };
153 |
154 | const dragged = (
155 | event: d3.D3DragEvent,
156 | d: Node,
157 | ) => {
158 | d.fx = event.x;
159 | d.fy = event.y;
160 | };
161 |
162 | const dragEnded = (
163 | event: d3.D3DragEvent,
164 | d: Node,
165 | ) => {
166 | if (!event.active) simulation.alphaTarget(0);
167 | d.fx = null;
168 | d.fy = null;
169 | };
170 |
171 | return d3
172 | .drag()
173 | .on("start", dragStarted)
174 | .on("drag", dragged)
175 | .on("end", dragEnded);
176 | };
177 |
178 | export const prepareGroups = (svg: SVGElement) => {
179 | if (d3.select(svg).select("g.links").empty()) {
180 | d3.select(svg).append("g").attr("class", "links");
181 | }
182 |
183 | if (d3.select(svg).select("g.nodes").empty()) {
184 | d3.select(svg).append("g").attr("class", "nodes");
185 | }
186 | };
187 |
188 | export const createSimulation = (width: number, height: number) => {
189 | return d3
190 | .forceSimulation()
191 | .force("link", d3.forceLink().id(getNodeId).distance(110))
192 | .force("charge", d3.forceManyBody())
193 | .force("center", d3.forceCenter(width / 2, height / 2))
194 | .force(
195 | "collide",
196 | d3
197 | .forceCollide()
198 | .radius((d) => (d.isMain ? 20 : 15))
199 | .iterations(2),
200 | );
201 | };
202 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/clappr.css:
--------------------------------------------------------------------------------
1 | #clappr-player {
2 | width: 100%;
3 | height: 100%;
4 | }
5 | @media (max-width: 376px) {
6 | #clappr-player {
7 | height: 200px;
8 | }
9 | }
10 |
11 | @media (max-width: 426px) {
12 | #clappr-player {
13 | height: 222px;
14 | }
15 | }
16 |
17 | @media (min-width: 427px) {
18 | #clappr-player {
19 | height: 304px;
20 | }
21 | }
22 |
23 | @media (min-width: 576px) {
24 | #clappr-player {
25 | height: 304px;
26 | }
27 | }
28 |
29 | @media (min-width: 768px) {
30 | #clappr-player {
31 | height: 253px;
32 | }
33 | }
34 |
35 | @media (min-width: 992px) {
36 | #clappr-player {
37 | height: 343px;
38 | }
39 | }
40 |
41 | @media (min-width: 1200px) {
42 | #clappr-player {
43 | height: 411px;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.tsx:
--------------------------------------------------------------------------------
1 | import "./hlsjs.css";
2 | import { useEffect, useRef } from "react";
3 | import { PlayerProps } from "../../../types";
4 | import { subscribeToUiEvents } from "../utils";
5 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
6 | import Hls from "hls.js";
7 |
8 | export const HlsjsPlayer = ({
9 | streamUrl,
10 | announceTrackers,
11 | swarmId,
12 | onPeerConnect,
13 | onPeerClose,
14 | onChunkDownloaded,
15 | onChunkUploaded,
16 | }: PlayerProps) => {
17 | const videoRef = useRef(null);
18 | const qualityRef = useRef(null);
19 |
20 | useEffect(() => {
21 | if (!videoRef.current || !Hls.isSupported()) return;
22 |
23 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
24 | const hls = new HlsWithP2P({
25 | p2p: {
26 | core: {
27 | announceTrackers,
28 | swarmId,
29 | },
30 | onHlsJsCreated(hls) {
31 | subscribeToUiEvents({
32 | engine: hls.p2pEngine,
33 | onPeerConnect,
34 | onPeerClose,
35 | onChunkDownloaded,
36 | onChunkUploaded,
37 | });
38 | },
39 | },
40 | });
41 |
42 | hls.attachMedia(videoRef.current);
43 | hls.loadSource(streamUrl);
44 |
45 | hls.on(Hls.Events.MANIFEST_PARSED, () => {
46 | if (!qualityRef.current) return;
47 | updateQualityOptions(hls, qualityRef.current);
48 | });
49 |
50 | return () => hls.destroy();
51 | }, [
52 | onPeerConnect,
53 | onPeerClose,
54 | onChunkDownloaded,
55 | onChunkUploaded,
56 | streamUrl,
57 | announceTrackers,
58 | swarmId,
59 | ]);
60 |
61 | const updateQualityOptions = (hls: Hls, selectElement: HTMLSelectElement) => {
62 | if (hls.levels.length < 2) {
63 | selectElement.style.display = "none";
64 | } else {
65 | selectElement.style.display = "block";
66 | selectElement.options.length = 0;
67 | selectElement.add(new Option("Auto", "-1"));
68 | hls.levels.forEach((level, index) => {
69 | const label = `${level.height}p (${Math.round(level.bitrate / 1000)}k)`;
70 | selectElement.add(new Option(label, index.toString()));
71 | });
72 |
73 | selectElement.addEventListener("change", () => {
74 | hls.currentLevel = parseInt(selectElement.value);
75 | });
76 | }
77 | };
78 |
79 | return Hls.isSupported() ? (
80 |
93 | ) : (
94 |
95 |
HLS is not supported in this browser
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsClapprPlayer.tsx:
--------------------------------------------------------------------------------
1 | import "../clappr.css";
2 | import { useEffect, useRef } from "react";
3 | import { PlayerProps } from "../../../types";
4 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
5 | import { subscribeToUiEvents } from "../utils";
6 | import Hls from "hls.js";
7 | import { useScripts } from "../../../hooks/useScripts";
8 |
9 | const SCRIPTS = [
10 | "https://cdn.jsdelivr.net/npm/@clappr/player@~0/dist/clappr.min.js",
11 | "https://cdn.jsdelivr.net/gh/clappr/clappr-level-selector-plugin@~0/dist/level-selector.min.js",
12 | ];
13 |
14 | export const HlsjsClapprPlayer = ({
15 | streamUrl,
16 | announceTrackers,
17 | swarmId,
18 | onPeerConnect,
19 | onPeerClose,
20 | onChunkDownloaded,
21 | onChunkUploaded,
22 | }: PlayerProps) => {
23 | const areScriptsLoaded = useScripts(SCRIPTS);
24 |
25 | const containerRef = useRef(null);
26 |
27 | useEffect(() => {
28 | if (!containerRef.current || !areScriptsLoaded || !Hls.isSupported()) {
29 | return;
30 | }
31 |
32 | const engine = new HlsJsP2PEngine({
33 | core: {
34 | announceTrackers,
35 | swarmId,
36 | },
37 | });
38 |
39 | subscribeToUiEvents({
40 | engine,
41 | onPeerConnect,
42 | onPeerClose,
43 | onChunkDownloaded,
44 | onChunkUploaded,
45 | });
46 |
47 | /* eslint-disable */
48 | // @ts-ignore
49 | const clapprPlayer = new Clappr.Player({
50 | parentId: `#${containerRef.current.id}`,
51 | source: streamUrl,
52 | mute: true,
53 | autoPlay: true,
54 | playback: {
55 | playInline: true,
56 | hlsjsConfig: {
57 | ...engine.getConfigForHlsJs(),
58 | },
59 | },
60 | plugins: [window.LevelSelector],
61 | width: "100%",
62 | height: "100%",
63 | });
64 |
65 | engine.bindHls(() => clapprPlayer.core.getCurrentPlayback()?._hls);
66 |
67 | return () => {
68 | clapprPlayer.destroy();
69 | engine.destroy();
70 | };
71 | /* eslint-enable */
72 | }, [
73 | areScriptsLoaded,
74 | announceTrackers,
75 | onChunkDownloaded,
76 | onChunkUploaded,
77 | onPeerConnect,
78 | onPeerClose,
79 | streamUrl,
80 | swarmId,
81 | ]);
82 |
83 | return Hls.isSupported() ? (
84 |
85 | ) : (
86 |
87 |
HLS is not supported in this browser
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsDPLayer.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { PlayerProps } from "../../../types";
3 | import DPlayer from "dplayer";
4 | import { subscribeToUiEvents } from "../utils";
5 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
6 | import Hls from "hls.js";
7 |
8 | export const HlsjsDPlayer = ({
9 | streamUrl,
10 | announceTrackers,
11 | swarmId,
12 | onPeerConnect,
13 | onPeerClose,
14 | onChunkDownloaded,
15 | onChunkUploaded,
16 | }: PlayerProps) => {
17 | const containerRef = useRef(null);
18 |
19 | useEffect(() => {
20 | if (!Hls.isSupported()) return;
21 |
22 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
23 |
24 | const hls = new HlsWithP2P({
25 | p2p: {
26 | core: {
27 | announceTrackers,
28 | swarmId,
29 | },
30 | onHlsJsCreated(hls) {
31 | subscribeToUiEvents({
32 | engine: hls.p2pEngine,
33 | onPeerConnect,
34 | onPeerClose,
35 | onChunkDownloaded,
36 | onChunkUploaded,
37 | });
38 | },
39 | },
40 | });
41 |
42 | const player = new DPlayer({
43 | container: containerRef.current,
44 |
45 | video: {
46 | url: "",
47 | type: "customHls",
48 | customType: {
49 | customHls: (video: HTMLVideoElement) => {
50 | hls.attachMedia(video);
51 | hls.loadSource(streamUrl);
52 | },
53 | },
54 | },
55 | });
56 |
57 | player.play();
58 |
59 | return () => {
60 | player.destroy();
61 | hls.destroy();
62 | };
63 | }, [
64 | streamUrl,
65 | announceTrackers,
66 | onPeerConnect,
67 | onPeerClose,
68 | onChunkDownloaded,
69 | onChunkUploaded,
70 | swarmId,
71 | ]);
72 |
73 | return Hls.isSupported() ? (
74 |
75 |
76 |
77 | ) : (
78 |
79 |
HLS is not supported in this browser
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsMediaElement.tsx:
--------------------------------------------------------------------------------
1 | import "mediaelement";
2 | import "mediaelement/build/mediaelementplayer.min.css";
3 | import { useEffect, useRef, useState } from "react";
4 | import Hls from "hls.js";
5 | import { HlsJsP2PEngine, HlsWithP2PInstance } from "p2p-media-loader-hlsjs";
6 | import { createVideoElements, subscribeToUiEvents } from "../utils";
7 | import { PlayerProps } from "../../../types";
8 |
9 | export const HlsjsMediaElement = ({
10 | streamUrl,
11 | announceTrackers,
12 | swarmId,
13 | onPeerConnect,
14 | onPeerClose,
15 | onChunkDownloaded,
16 | onChunkUploaded,
17 | }: PlayerProps) => {
18 | const [isHlsSupported, setIsHlsSupported] = useState(true);
19 |
20 | const containerRef = useRef(null);
21 | /* eslint-disable */
22 | // @ts-ignore
23 | useEffect(() => {
24 | if (!containerRef.current) return;
25 | if (!Hls.isSupported()) {
26 | setIsHlsSupported(false);
27 | return;
28 | }
29 |
30 | const { videoContainer, videoElement } = createVideoElements();
31 |
32 | containerRef.current.appendChild(videoContainer);
33 |
34 | window.Hls = HlsJsP2PEngine.injectMixin(Hls);
35 |
36 | // @ts-ignore
37 | const player = new MediaElementPlayer(videoElement.id, {
38 | iconSprite: "/mejs-controls.svg",
39 | videoHeight: "100%",
40 | hls: {
41 | p2p: {
42 | onHlsJsCreated: (hls: HlsWithP2PInstance) => {
43 | subscribeToUiEvents({
44 | engine: hls.p2pEngine,
45 | onPeerConnect,
46 | onPeerClose,
47 | onChunkDownloaded,
48 | onChunkUploaded,
49 | });
50 | },
51 | core: {
52 | announceTrackers,
53 | swarmId,
54 | },
55 | },
56 | },
57 | });
58 |
59 | player.setSrc(streamUrl);
60 | player.load();
61 |
62 | return () => {
63 | window.Hls = undefined;
64 | player?.remove();
65 | videoContainer.remove();
66 | };
67 | /* eslint-enable */
68 | }, [
69 | announceTrackers,
70 | onChunkDownloaded,
71 | onChunkUploaded,
72 | onPeerConnect,
73 | onPeerClose,
74 | streamUrl,
75 | swarmId,
76 | ]);
77 |
78 | return isHlsSupported ? (
79 |
80 | ) : (
81 |
82 |
HLS is not supported in this browser
83 |
84 | );
85 | };
86 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsOpenPlayer.tsx:
--------------------------------------------------------------------------------
1 | import "openplayerjs/dist/openplayer.min.css";
2 | import OpenPlayerJS from "openplayerjs";
3 | import { useEffect, useRef } from "react";
4 | import { PlayerProps } from "../../../types";
5 | import { HlsJsP2PEngine, HlsWithP2PInstance } from "p2p-media-loader-hlsjs";
6 | import Hls from "hls.js";
7 | import { createVideoElements, subscribeToUiEvents } from "../utils";
8 |
9 | export const HlsjsOpenPlayer = ({
10 | streamUrl,
11 | announceTrackers,
12 | swarmId,
13 | onPeerConnect,
14 | onPeerClose,
15 | onChunkDownloaded,
16 | onChunkUploaded,
17 | }: PlayerProps) => {
18 | const playerContainerRef = useRef(null);
19 |
20 | useEffect(() => {
21 | if (!playerContainerRef.current || !Hls.isSupported()) return;
22 |
23 | window.Hls = HlsJsP2PEngine.injectMixin(Hls);
24 |
25 | let isCleanedUp = false;
26 | let player: OpenPlayerJS | undefined;
27 |
28 | const { videoContainer, videoElement } = createVideoElements({
29 | videoClassName: "op-player__media",
30 | });
31 |
32 | playerContainerRef.current.appendChild(videoContainer);
33 |
34 | const cleanup = () => {
35 | isCleanedUp = true;
36 | player?.destroy();
37 | videoElement.remove();
38 | videoContainer.remove();
39 | window.Hls = undefined;
40 | };
41 |
42 | const initPlayer = async () => {
43 | const playerInit = new OpenPlayerJS(videoElement, {
44 | hls: {
45 | p2p: {
46 | onHlsJsCreated: (hls: HlsWithP2PInstance) => {
47 | subscribeToUiEvents({
48 | engine: hls.p2pEngine,
49 | onPeerConnect,
50 | onPeerClose,
51 | onChunkDownloaded,
52 | onChunkUploaded,
53 | });
54 | },
55 | core: {
56 | announceTrackers,
57 | swarmId,
58 | },
59 | },
60 | },
61 | controls: {
62 | layers: {
63 | left: ["play", "time", "volume"],
64 | right: ["settings", "fullscreen", "levels"],
65 | middle: ["progress"],
66 | },
67 | },
68 | });
69 |
70 | playerInit.src = [
71 | {
72 | src: streamUrl,
73 | type: "application/x-mpegURL",
74 | },
75 | ];
76 |
77 | try {
78 | await playerInit.init();
79 |
80 | player = playerInit;
81 | } catch (error) {
82 | player = playerInit;
83 |
84 | cleanup();
85 | // eslint-disable-next-line no-console
86 | console.error("Error initializing OpenPlayerJS", error);
87 | }
88 |
89 | if (isCleanedUp) cleanup();
90 | };
91 |
92 | void initPlayer();
93 |
94 | return () => cleanup();
95 | }, [
96 | announceTrackers,
97 | onChunkDownloaded,
98 | onChunkUploaded,
99 | onPeerConnect,
100 | onPeerClose,
101 | streamUrl,
102 | swarmId,
103 | ]);
104 |
105 | return Hls.isSupported() ? (
106 |
107 | ) : (
108 |
109 |
HLS is not supported in this browser
110 |
111 | );
112 | };
113 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsPlyr.tsx:
--------------------------------------------------------------------------------
1 | import "plyr/dist/plyr.css";
2 | import Plyr, { Options } from "plyr";
3 | import { useEffect, useRef } from "react";
4 | import { PlayerProps } from "../../../types";
5 | import Hls from "hls.js";
6 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
7 | import { createVideoElements, subscribeToUiEvents } from "../utils";
8 |
9 | export const HlsjsPlyr = ({
10 | streamUrl,
11 | announceTrackers,
12 | swarmId,
13 | onPeerConnect,
14 | onPeerClose,
15 | onChunkDownloaded,
16 | onChunkUploaded,
17 | }: PlayerProps) => {
18 | const containerRef = useRef(null);
19 |
20 | useEffect(() => {
21 | if (!containerRef.current || !Hls.isSupported()) return;
22 |
23 | let player: Plyr | undefined;
24 |
25 | const { videoContainer, videoElement } = createVideoElements();
26 |
27 | containerRef.current.appendChild(videoContainer);
28 |
29 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
30 |
31 | const hls = new HlsWithP2P({
32 | p2p: {
33 | core: {
34 | announceTrackers,
35 | swarmId,
36 | },
37 | onHlsJsCreated(hls) {
38 | subscribeToUiEvents({
39 | engine: hls.p2pEngine,
40 | onPeerConnect,
41 | onPeerClose,
42 | onChunkDownloaded,
43 | onChunkUploaded,
44 | });
45 | },
46 | },
47 | });
48 |
49 | hls.on(Hls.Events.MANIFEST_PARSED, () => {
50 | const { levels } = hls;
51 |
52 | const quality: Options["quality"] = {
53 | default: levels[levels.length - 1].height,
54 | options: levels.map((level) => level.height),
55 | forced: true,
56 | onChange: (newQuality: number) => {
57 | levels.forEach((level, levelIndex) => {
58 | if (level.height === newQuality) {
59 | hls.currentLevel = levelIndex;
60 | }
61 | });
62 | },
63 | };
64 |
65 | player = new Plyr(videoElement, {
66 | quality,
67 | autoplay: true,
68 | muted: true,
69 | });
70 | });
71 |
72 | hls.attachMedia(videoElement);
73 | hls.loadSource(streamUrl);
74 |
75 | return () => {
76 | player?.destroy();
77 | videoContainer.remove();
78 | hls.destroy();
79 | };
80 | }, [
81 | announceTrackers,
82 | onChunkDownloaded,
83 | onChunkUploaded,
84 | onPeerConnect,
85 | onPeerClose,
86 | streamUrl,
87 | swarmId,
88 | ]);
89 |
90 | return Hls.isSupported() ? (
91 |
92 | ) : (
93 |
94 |
HLS is not supported in this browser
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsVidstack.tsx:
--------------------------------------------------------------------------------
1 | import "@vidstack/react/player/styles/default/theme.css";
2 | import "@vidstack/react/player/styles/default/layouts/video.css";
3 | import {
4 | MediaPlayer,
5 | MediaProvider,
6 | isHLSProvider,
7 | type MediaProviderAdapter,
8 | } from "@vidstack/react";
9 | import {
10 | defaultLayoutIcons,
11 | DefaultVideoLayout,
12 | } from "@vidstack/react/player/layouts/default";
13 | import { PlayerProps } from "../../../types";
14 | import { HlsJsP2PEngine, HlsWithP2PConfig } from "p2p-media-loader-hlsjs";
15 | import { subscribeToUiEvents } from "../utils";
16 | import { useCallback } from "react";
17 | import Hls from "hls.js";
18 |
19 | export const HlsjsVidstack = ({
20 | streamUrl,
21 | announceTrackers,
22 | swarmId,
23 | onPeerConnect,
24 | onPeerClose,
25 | onChunkDownloaded,
26 | onChunkUploaded,
27 | }: PlayerProps) => {
28 | const onProviderChange = useCallback(
29 | (provider: MediaProviderAdapter | null) => {
30 | if (isHLSProvider(provider)) {
31 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
32 |
33 | provider.library = HlsWithP2P as unknown as typeof Hls;
34 |
35 | const config: HlsWithP2PConfig = {
36 | p2p: {
37 | core: {
38 | announceTrackers,
39 | swarmId,
40 | },
41 | onHlsJsCreated: (hls) => {
42 | subscribeToUiEvents({
43 | engine: hls.p2pEngine,
44 | onPeerConnect,
45 | onPeerClose,
46 | onChunkDownloaded,
47 | onChunkUploaded,
48 | });
49 | },
50 | },
51 | };
52 |
53 | provider.config = config;
54 | }
55 | },
56 | [
57 | announceTrackers,
58 | onChunkDownloaded,
59 | onChunkUploaded,
60 | onPeerConnect,
61 | onPeerClose,
62 | swarmId,
63 | ],
64 | );
65 |
66 | return (
67 |
68 |
75 |
76 |
77 |
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsVidstackIndexedDB.tsx:
--------------------------------------------------------------------------------
1 | import "./vidstack_indexed_db.css";
2 | import "@vidstack/react/player/styles/default/theme.css";
3 | import "@vidstack/react/player/styles/default/layouts/video.css";
4 | import {
5 | MediaPlayer,
6 | MediaProvider,
7 | isHLSProvider,
8 | type MediaProviderAdapter,
9 | } from "@vidstack/react";
10 | import {
11 | defaultLayoutIcons,
12 | DefaultVideoLayout,
13 | } from "@vidstack/react/player/layouts/default";
14 | import { PlayerProps } from "../../../types";
15 | import { HlsJsP2PEngine, HlsWithP2PConfig } from "p2p-media-loader-hlsjs";
16 | import { subscribeToUiEvents } from "../utils";
17 | import { useCallback } from "react";
18 | import Hls from "hls.js";
19 | import { IndexedDbStorage } from "../../../custom-segment-storage-example/indexed-db-storage";
20 |
21 | export const HlsjsVidstackIndexedDB = ({
22 | streamUrl,
23 | announceTrackers,
24 | onPeerConnect,
25 | onPeerClose,
26 | onChunkDownloaded,
27 | onChunkUploaded,
28 | }: PlayerProps) => {
29 | const onProviderChange = useCallback(
30 | (provider: MediaProviderAdapter | null) => {
31 | if (isHLSProvider(provider)) {
32 | const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
33 |
34 | provider.library = HlsWithP2P as unknown as typeof Hls;
35 |
36 | const storageFactory = (_isLive: boolean) => new IndexedDbStorage();
37 |
38 | const config: HlsWithP2PConfig = {
39 | p2p: {
40 | core: {
41 | announceTrackers,
42 | customSegmentStorageFactory: storageFactory,
43 | },
44 | onHlsJsCreated: (hls) => {
45 | subscribeToUiEvents({
46 | engine: hls.p2pEngine,
47 | onPeerConnect,
48 | onPeerClose,
49 | onChunkDownloaded,
50 | onChunkUploaded,
51 | });
52 | },
53 | },
54 | };
55 |
56 | provider.config = config;
57 | }
58 | },
59 | [
60 | announceTrackers,
61 | onChunkDownloaded,
62 | onChunkUploaded,
63 | onPeerConnect,
64 | onPeerClose,
65 | ],
66 | );
67 |
68 | return (
69 |
70 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | Note: Clearing of stored video segments is not
84 | implemented in this example. To remove cached segments, please clear
85 | your browser's IndexedDB manually.{" "}
86 |
92 | View Source Code
93 |
94 |
95 |
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/hlsjs.css:
--------------------------------------------------------------------------------
1 | .select-container {
2 | position: relative;
3 | width: 150px;
4 | margin-left: auto;
5 | }
6 |
7 | .select-container select {
8 | width: 100%;
9 | padding: 8px 16px;
10 | border: 1px solid #ccc;
11 | border-radius: 4px;
12 | background-color: white;
13 | font-size: 16px;
14 | color: #333;
15 | appearance: none;
16 | -webkit-appearance: none;
17 | -moz-appearance: none;
18 | cursor: pointer;
19 | }
20 |
21 | .select-container:after {
22 | content: "▼";
23 | position: absolute;
24 | top: 50%;
25 | right: 10px;
26 | transform: translateY(-50%);
27 | color: #888;
28 | pointer-events: none;
29 | font-size: 12px;
30 | }
31 |
32 | .select-container select:hover {
33 | border-color: #888;
34 | cursor: pointer;
35 | }
36 |
37 | .select-container select:focus {
38 | outline: none;
39 | border-color: #555;
40 | }
41 |
42 | .select-container select:disabled {
43 | background-color: #eee;
44 | color: #666;
45 | cursor: not-allowed;
46 | }
47 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/hlsjs/vidstack_indexed_db.css:
--------------------------------------------------------------------------------
1 | .notice {
2 | margin-top: 10px;
3 | padding: 10px;
4 | border: 1px solid #f0ad4e;
5 | background-color: #fcf8e3;
6 | border-radius: 4px;
7 | }
8 |
9 | .notice p {
10 | margin: 0;
11 | color: #8a6d3b;
12 | }
13 |
14 | .source-code-link {
15 | color: #0275d8;
16 | text-decoration: none;
17 | font-weight: bold;
18 | }
19 |
20 | .source-code-link:hover {
21 | text-decoration: underline;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/loader/Loader.tsx:
--------------------------------------------------------------------------------
1 | // Loader.tsx
2 | import "./loader.css";
3 |
4 | export const Loader = () => {
5 | return (
6 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/loader/loader.css:
--------------------------------------------------------------------------------
1 | .loader-container {
2 | width: 100%;
3 | height: 20%;
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | }
8 |
9 | @media (min-width: 768px) {
10 | .loader-container {
11 | height: 40%;
12 | }
13 | }
14 |
15 | .loader {
16 | width: 48px;
17 | height: 48px;
18 | border: 5px solid #fff;
19 | border-bottom-color: #ff3d00;
20 | border-radius: 50%;
21 | display: inline-block;
22 | box-sizing: border-box;
23 | animation: rotation 1s linear infinite;
24 | }
25 |
26 | @keyframes rotation {
27 | 0% {
28 | transform: rotate(0deg);
29 | }
30 | 100% {
31 | transform: rotate(360deg);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/shaka/Shaka.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
3 | import { PlayerProps } from "../../../types";
4 | import "shaka-player/dist/controls.css";
5 | import shaka from "./shaka-import";
6 | import { createVideoElements, subscribeToUiEvents } from "../utils";
7 |
8 | export const Shaka = ({
9 | streamUrl,
10 | announceTrackers,
11 | swarmId,
12 | onPeerConnect,
13 | onPeerClose,
14 | onChunkDownloaded,
15 | onChunkUploaded,
16 | }: PlayerProps) => {
17 | const playerContainerRef = useRef(null);
18 |
19 | useEffect(() => {
20 | ShakaP2PEngine.registerPlugins(shaka);
21 | return () => ShakaP2PEngine.unregisterPlugins(shaka);
22 | }, []);
23 |
24 | useEffect(() => {
25 | if (!playerContainerRef.current || !shaka.Player.isBrowserSupported()) {
26 | return;
27 | }
28 |
29 | const { videoElement, videoContainer } = createVideoElements({
30 | aspectRatio: "auto",
31 | });
32 |
33 | playerContainerRef.current.appendChild(videoContainer);
34 |
35 | let isCleanedUp = false;
36 | let shakaP2PEngine: ShakaP2PEngine | undefined;
37 | let player: shaka.Player | undefined;
38 | let ui: shaka.ui.Overlay | undefined;
39 |
40 | const cleanup = () => {
41 | isCleanedUp = true;
42 | videoElement.remove();
43 | videoContainer.remove();
44 | void player?.destroy();
45 | void ui?.destroy();
46 | shakaP2PEngine?.destroy();
47 | };
48 |
49 | const setupPlayer = async () => {
50 | const playerInit = new shaka.Player();
51 | const uiInit = new shaka.ui.Overlay(
52 | playerInit,
53 | videoContainer,
54 | videoElement,
55 | );
56 |
57 | const shakaP2PEngineInit = new ShakaP2PEngine(
58 | {
59 | core: {
60 | announceTrackers,
61 | swarmId,
62 | },
63 | },
64 | shaka,
65 | );
66 |
67 | subscribeToUiEvents({
68 | engine: shakaP2PEngineInit,
69 | onPeerConnect,
70 | onPeerClose,
71 | onChunkDownloaded,
72 | onChunkUploaded,
73 | });
74 |
75 | try {
76 | await playerInit.attach(videoElement);
77 |
78 | player = playerInit;
79 | ui = uiInit;
80 | shakaP2PEngine = shakaP2PEngineInit;
81 | } catch (error) {
82 | player = playerInit;
83 | ui = uiInit;
84 | shakaP2PEngine = shakaP2PEngineInit;
85 |
86 | cleanup();
87 | // eslint-disable-next-line no-console
88 | console.error("Error attaching shaka player", error);
89 | }
90 |
91 | if (isCleanedUp) {
92 | cleanup();
93 | return;
94 | }
95 |
96 | shakaP2PEngineInit.bindShakaPlayer(playerInit);
97 | await playerInit.load(streamUrl);
98 | };
99 |
100 | void setupPlayer();
101 |
102 | return cleanup;
103 | }, [
104 | announceTrackers,
105 | onChunkDownloaded,
106 | onChunkUploaded,
107 | onPeerConnect,
108 | onPeerClose,
109 | streamUrl,
110 | swarmId,
111 | ]);
112 |
113 | return shaka.Player.isBrowserSupported() ? (
114 |
115 | ) : (
116 |
117 |
Shaka Player is not supported in this browser
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/shaka/ShakaClappr.tsx:
--------------------------------------------------------------------------------
1 | import "../clappr.css";
2 | import { useEffect, useRef } from "react";
3 | import { PlayerProps } from "../../../types";
4 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
5 | import { subscribeToUiEvents } from "../utils";
6 | import { useScripts } from "../../../hooks/useScripts";
7 | import { Loader } from "../loader/Loader";
8 |
9 | const SCRIPTS = [
10 | "https://cdn.jsdelivr.net/npm/shaka-player@~4/dist/shaka-player.compiled.min.js",
11 | "https://cdn.jsdelivr.net/npm/@clappr/player@~0/dist/clappr.min.js",
12 | "https://cdn.jsdelivr.net/gh/clappr/clappr-level-selector-plugin@~0/dist/level-selector.min.js",
13 | "https://cdn.jsdelivr.net/npm/dash-shaka-playback@~3/dist/dash-shaka-playback.external.min.js",
14 | ];
15 |
16 | export const ShakaClappr = ({
17 | streamUrl,
18 | announceTrackers,
19 | swarmId,
20 | onPeerConnect,
21 | onPeerClose,
22 | onChunkDownloaded,
23 | onChunkUploaded,
24 | }: PlayerProps) => {
25 | const areScriptsLoaded = useScripts(SCRIPTS);
26 |
27 | const containerRef = useRef(null);
28 |
29 | useEffect(() => {
30 | if (!areScriptsLoaded) return;
31 |
32 | ShakaP2PEngine.registerPlugins(window.shaka);
33 | return () => ShakaP2PEngine.unregisterPlugins(window.shaka);
34 | }, [areScriptsLoaded]);
35 |
36 | useEffect(() => {
37 | if (
38 | !areScriptsLoaded ||
39 | !containerRef.current ||
40 | !window.shaka.Player.isBrowserSupported()
41 | ) {
42 | return;
43 | }
44 |
45 | const shakaP2PEngine = new ShakaP2PEngine({
46 | core: {
47 | announceTrackers,
48 | swarmId,
49 | },
50 | });
51 |
52 | /* eslint-disable */
53 | // @ts-ignore
54 | const clapprPlayer = new Clappr.Player({
55 | parentId: `#${containerRef.current.id}`,
56 | source: streamUrl,
57 | plugins: [window.DashShakaPlayback, window.LevelSelector],
58 | mute: true,
59 | autoPlay: true,
60 | playback: {
61 | playInline: true,
62 | },
63 | shakaOnBeforeLoad: (shakaPlayerInstance: shaka.Player) => {
64 | subscribeToUiEvents({
65 | engine: shakaP2PEngine,
66 | onPeerConnect,
67 | onPeerClose,
68 | onChunkDownloaded,
69 | onChunkUploaded,
70 | });
71 |
72 | shakaP2PEngine.bindShakaPlayer(shakaPlayerInstance);
73 | },
74 | width: "100%",
75 | height: "100%",
76 | });
77 |
78 | return () => {
79 | shakaP2PEngine.destroy();
80 | clapprPlayer.destroy();
81 | };
82 | /* eslint-enable */
83 | }, [
84 | announceTrackers,
85 | onChunkDownloaded,
86 | onChunkUploaded,
87 | onPeerConnect,
88 | onPeerClose,
89 | streamUrl,
90 | swarmId,
91 | areScriptsLoaded,
92 | ]);
93 |
94 | if (!areScriptsLoaded) {
95 | return ;
96 | }
97 |
98 | return window.shaka.Player.isBrowserSupported() ? (
99 |
100 | ) : (
101 |
102 |
Shaka Player is not supported in this browser
103 |
104 | );
105 | };
106 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/shaka/ShakaDPlayer.tsx:
--------------------------------------------------------------------------------
1 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
2 | import { PlayerProps } from "../../../types";
3 | import { useEffect, useRef } from "react";
4 | import DPlayer from "dplayer";
5 | import { subscribeToUiEvents } from "../utils";
6 | import shaka from "./shaka-import";
7 |
8 | export const ShakaDPlayer = ({
9 | streamUrl,
10 | announceTrackers,
11 | swarmId,
12 | onPeerConnect,
13 | onPeerClose,
14 | onChunkDownloaded,
15 | onChunkUploaded,
16 | }: PlayerProps) => {
17 | const containerRef = useRef(null);
18 |
19 | useEffect(() => {
20 | ShakaP2PEngine.registerPlugins(shaka);
21 | return () => ShakaP2PEngine.unregisterPlugins(shaka);
22 | }, []);
23 |
24 | useEffect(() => {
25 | if (!shaka.Player.isBrowserSupported()) return;
26 |
27 | const shakaP2PEngine = new ShakaP2PEngine(
28 | {
29 | core: {
30 | announceTrackers,
31 | swarmId,
32 | },
33 | },
34 | shaka,
35 | );
36 |
37 | const player = new DPlayer({
38 | container: containerRef.current,
39 | video: {
40 | url: "",
41 | type: "customHlsOrDash",
42 | customType: {
43 | customHlsOrDash: (video: HTMLVideoElement) => {
44 | const shakaPlayer = new shaka.Player();
45 | void shakaPlayer.attach(video);
46 |
47 | subscribeToUiEvents({
48 | engine: shakaP2PEngine,
49 | onPeerConnect,
50 | onPeerClose,
51 | onChunkDownloaded,
52 | onChunkUploaded,
53 | });
54 |
55 | shakaP2PEngine.bindShakaPlayer(shakaPlayer);
56 | void shakaPlayer.load(streamUrl);
57 | },
58 | },
59 | },
60 | });
61 |
62 | return () => {
63 | shakaP2PEngine.destroy();
64 | player.destroy();
65 | };
66 | }, [
67 | announceTrackers,
68 | onChunkDownloaded,
69 | onChunkUploaded,
70 | onPeerConnect,
71 | onPeerClose,
72 | streamUrl,
73 | swarmId,
74 | ]);
75 |
76 | return shaka.Player.isBrowserSupported() ? (
77 |
78 |
79 |
80 | ) : (
81 |
82 |
Shaka Player is not supported in this browser
83 |
84 | );
85 | };
86 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/shaka/ShakaPlyr.tsx:
--------------------------------------------------------------------------------
1 | import "plyr/dist/plyr.css";
2 | import { useEffect, useRef } from "react";
3 | import shaka from "../shaka/shaka-import";
4 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
5 | import { PlayerProps } from "../../../types";
6 | import Plyr, { Options } from "plyr";
7 | import { createVideoElements, subscribeToUiEvents } from "../utils";
8 |
9 | export const ShakaPlyr = ({
10 | streamUrl,
11 | announceTrackers,
12 | swarmId,
13 | onPeerConnect,
14 | onPeerClose,
15 | onChunkDownloaded,
16 | onChunkUploaded,
17 | }: PlayerProps) => {
18 | const containerRef = useRef(null);
19 |
20 | useEffect(() => {
21 | ShakaP2PEngine.registerPlugins(shaka);
22 | return () => ShakaP2PEngine.unregisterPlugins(shaka);
23 | }, []);
24 |
25 | useEffect(() => {
26 | if (!containerRef.current || !shaka.Player.isBrowserSupported()) return;
27 |
28 | const { videoContainer, videoElement } = createVideoElements();
29 |
30 | containerRef.current.appendChild(videoContainer);
31 |
32 | let plyrPlayer: Plyr | undefined;
33 | let playerShaka: shaka.Player | undefined;
34 | let shakaP2PEngine: ShakaP2PEngine | undefined;
35 | let isCleanedUp = false;
36 |
37 | const cleanup = () => {
38 | isCleanedUp = true;
39 | shakaP2PEngine?.destroy();
40 | void playerShaka?.destroy();
41 | plyrPlayer?.destroy();
42 | videoContainer.remove();
43 | };
44 |
45 | const initPlayer = async () => {
46 | const shakaP2PEngineInit = new ShakaP2PEngine(
47 | {
48 | core: {
49 | announceTrackers,
50 | swarmId,
51 | },
52 | },
53 | shaka,
54 | );
55 | const shakaPlayerInit = new shaka.Player();
56 |
57 | subscribeToUiEvents({
58 | engine: shakaP2PEngineInit,
59 | onPeerConnect,
60 | onPeerClose,
61 | onChunkDownloaded,
62 | onChunkUploaded,
63 | });
64 |
65 | try {
66 | await shakaPlayerInit.attach(videoElement);
67 |
68 | playerShaka = shakaPlayerInit;
69 | shakaP2PEngine = shakaP2PEngineInit;
70 |
71 | if (isCleanedUp) cleanup();
72 | } catch (error) {
73 | playerShaka = shakaPlayerInit;
74 | shakaP2PEngine = shakaP2PEngineInit;
75 |
76 | cleanup();
77 | // eslint-disable-next-line no-console
78 | console.error("Error attaching shaka player", error);
79 | }
80 |
81 | if (isCleanedUp) {
82 | cleanup();
83 | return;
84 | }
85 |
86 | shakaP2PEngineInit.bindShakaPlayer(shakaPlayerInit);
87 | await shakaPlayerInit.load(streamUrl);
88 |
89 | const levels = shakaPlayerInit.getVariantTracks();
90 | const quality: Options["quality"] = {
91 | default: levels[levels.length - 1]?.height ?? 0,
92 | options: levels
93 | .map((level) => level.height)
94 | .filter((height): height is number => height != null)
95 | .sort((a, b) => a - b),
96 | forced: true,
97 | onChange: (newQuality: number) => {
98 | levels.forEach((level) => {
99 | if (level.height === newQuality) {
100 | shakaPlayerInit.configure({
101 | abr: { enabled: false },
102 | });
103 | shakaPlayerInit.selectVariantTrack(level, true);
104 | }
105 | });
106 | },
107 | };
108 |
109 | plyrPlayer = new Plyr(videoElement, {
110 | quality,
111 | autoplay: true,
112 | muted: true,
113 | });
114 | };
115 |
116 | void initPlayer();
117 |
118 | return () => cleanup();
119 | }, [
120 | announceTrackers,
121 | onChunkDownloaded,
122 | onChunkUploaded,
123 | onPeerConnect,
124 | onPeerClose,
125 | streamUrl,
126 | swarmId,
127 | ]);
128 |
129 | return shaka.Player.isBrowserSupported() ? (
130 |
131 | ) : (
132 |
133 |
Shaka Player is not supported in this browser
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/shaka/shaka-import.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2 | // @ts-ignore
3 | import shakaModule from "shaka-player/dist/shaka-player.ui";
4 | export default shakaModule as typeof shaka;
5 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/components/players/utils.ts:
--------------------------------------------------------------------------------
1 | import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs";
2 | import { PlayerEvents } from "./../../types";
3 | import { ShakaP2PEngine } from "p2p-media-loader-shaka";
4 |
5 | type UIEventsProps = PlayerEvents & {
6 | engine: HlsJsP2PEngine | ShakaP2PEngine;
7 | };
8 |
9 | export const subscribeToUiEvents = ({
10 | engine,
11 | onPeerConnect,
12 | onPeerClose,
13 | onChunkDownloaded,
14 | onChunkUploaded,
15 | }: UIEventsProps) => {
16 | if (onPeerConnect) engine.addEventListener("onPeerConnect", onPeerConnect);
17 | if (onPeerClose) {
18 | engine.addEventListener("onPeerClose", onPeerClose);
19 | }
20 | if (onChunkDownloaded) {
21 | engine.addEventListener("onChunkDownloaded", onChunkDownloaded);
22 | }
23 | if (onChunkUploaded) {
24 | engine.addEventListener("onChunkUploaded", onChunkUploaded);
25 | }
26 | };
27 |
28 | interface VideoElementsOptions {
29 | videoId?: string;
30 | videoClassName?: string;
31 | containerClassName?: string;
32 | playIsInline?: boolean;
33 | autoplay?: boolean;
34 | muted?: boolean;
35 | aspectRatio?: string | null;
36 | }
37 |
38 | export const createVideoElements = (options: VideoElementsOptions = {}) => {
39 | const {
40 | videoId = "player",
41 | videoClassName = "",
42 | containerClassName = "video-container",
43 | playIsInline = true,
44 | autoplay = true,
45 | muted = true,
46 | aspectRatio = null,
47 | } = options;
48 |
49 | const videoContainer = document.createElement("div");
50 | videoContainer.className = containerClassName;
51 |
52 | const videoElement = document.createElement("video");
53 | videoElement.className = videoClassName;
54 | videoElement.id = videoId;
55 | videoElement.playsInline = playIsInline;
56 | videoElement.autoplay = autoplay;
57 | videoElement.muted = muted;
58 |
59 | if (aspectRatio) {
60 | videoElement.style.aspectRatio = aspectRatio;
61 | }
62 |
63 | videoContainer.appendChild(videoElement);
64 |
65 | return { videoContainer, videoElement };
66 | };
67 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { Core } from "p2p-media-loader-core";
2 |
3 | export const PLAYERS = {
4 | vidstack_hls: "Vidstack",
5 | hlsjs_hls: "Hls.js",
6 | dplayer_hls: "DPlayer",
7 | clappr_hls: "Clappr",
8 | plyr_hls: "Plyr",
9 | openPlayer_hls: "OpenPlayerJS",
10 | mediaElement_hls: "MediaElement",
11 | vidstack_indexeddb_hls: "Vidstack IndexedDB example",
12 | shaka: "Shaka",
13 | dplayer_shaka: "DPlayer",
14 | clappr_shaka: "Clappr (DASH only)",
15 | plyr_shaka: "Plyr",
16 | } as const;
17 | export const DEFAULT_STREAM =
18 | "https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8";
19 | export const COLORS = {
20 | yellow: "#faf21b",
21 | lightOrange: "#ff7f0e",
22 | lightBlue: "#ADD8E6",
23 | torchRed: "#ff1745",
24 | };
25 | export const DEFAULT_TRACKERS =
26 | Core.DEFAULT_STREAM_CONFIG.announceTrackers.join(",");
27 | export const DEBUG_COMPONENT_ENABLED = "true";
28 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-wrapper.ts:
--------------------------------------------------------------------------------
1 | export class IndexedDbWrapper {
2 | private db: IDBDatabase | null = null;
3 |
4 | constructor(
5 | private readonly dbName: string,
6 | private readonly dbVersion: number,
7 | private readonly infoItemsStoreName: string,
8 | private readonly dataItemsStoreName: string,
9 | ) {}
10 |
11 | async openDatabase(): Promise {
12 | return new Promise((resolve, reject) => {
13 | const request = indexedDB.open(this.dbName, this.dbVersion);
14 |
15 | request.onerror = () => reject(new Error("Failed to open database."));
16 | request.onsuccess = () => {
17 | this.db = request.result;
18 | resolve();
19 | };
20 | request.onupgradeneeded = (event) => {
21 | this.db = (event.target as IDBOpenDBRequest).result;
22 | this.createObjectStores(this.db);
23 | };
24 | });
25 | }
26 |
27 | private createObjectStores(db: IDBDatabase): void {
28 | if (!db.objectStoreNames.contains(this.dataItemsStoreName)) {
29 | db.createObjectStore(this.dataItemsStoreName, { keyPath: "storageId" });
30 | }
31 | if (!db.objectStoreNames.contains(this.infoItemsStoreName)) {
32 | db.createObjectStore(this.infoItemsStoreName, { keyPath: "storageId" });
33 | }
34 | }
35 |
36 | async getAll(storeName: string): Promise {
37 | return this.performTransaction(storeName, "readonly", (store) =>
38 | store.getAll(),
39 | );
40 | }
41 |
42 | async put(storeName: string, item: unknown): Promise {
43 | await this.performTransaction(storeName, "readwrite", (store) =>
44 | store.put(item),
45 | );
46 | }
47 |
48 | async get(storeName: string, key: IDBValidKey): Promise {
49 | return this.performTransaction(storeName, "readonly", (store) =>
50 | store.get(key),
51 | );
52 | }
53 |
54 | async delete(storeName: string, key: IDBValidKey): Promise {
55 | await this.performTransaction(storeName, "readwrite", (store) =>
56 | store.delete(key),
57 | );
58 | }
59 |
60 | private async performTransaction(
61 | storeName: string,
62 | mode: IDBTransactionMode,
63 | operation: (store: IDBObjectStore) => IDBRequest,
64 | ): Promise {
65 | return new Promise((resolve, reject) => {
66 | if (!this.db) throw new Error("Database not initialized");
67 |
68 | const transaction = this.db.transaction(storeName, mode);
69 | const store = transaction.objectStore(storeName);
70 | const request = operation(store);
71 |
72 | request.onerror = () => reject(new Error("IndexedDB operation failed"));
73 |
74 | request.onsuccess = () => {
75 | const result = request.result as T;
76 | resolve(result);
77 | };
78 | });
79 | }
80 |
81 | closeDatabase(): void {
82 | if (!this.db) return;
83 | this.db.close();
84 | this.db = null;
85 | }
86 |
87 | async deleteDatabase(): Promise {
88 | this.closeDatabase();
89 | return new Promise((resolve, reject) => {
90 | const request = indexedDB.deleteDatabase(this.dbName);
91 | request.onsuccess = () => resolve();
92 | request.onerror = () => reject(new Error("Failed to delete database."));
93 | });
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/hooks/useQueryParams.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2 | import { DEFAULT_STREAM, DEFAULT_TRACKERS, PLAYERS } from "../constants";
3 |
4 | type QueryParamsType = Record;
5 |
6 | function getInitialParams(
7 | searchParams: URLSearchParams,
8 | defaultParams: QueryParamsType,
9 | ): QueryParamsType {
10 | return Object.keys(defaultParams).reduce((params, key) => {
11 | params[key] = searchParams.get(key) ?? defaultParams[key];
12 | return params;
13 | }, {});
14 | }
15 |
16 | export function useQueryParams(streamUri?: string) {
17 | const defaultParams = useMemo(() => {
18 | return {
19 | player: Object.keys(PLAYERS)[0],
20 | streamUrl: streamUri ?? DEFAULT_STREAM,
21 | trackers: DEFAULT_TRACKERS,
22 | debug: "",
23 | swarmId: "",
24 | } as QueryParamsType;
25 | }, [streamUri]);
26 |
27 | const searchParamsRef = useRef(new URLSearchParams(window.location.search));
28 | const [queryParams, setQueryParams] = useState(() =>
29 | getInitialParams(searchParamsRef.current, defaultParams),
30 | );
31 |
32 | const updateQueryParamsFromURL = useCallback(() => {
33 | const searchParams = searchParamsRef.current;
34 | const newParams = getInitialParams(searchParams, defaultParams);
35 |
36 | setQueryParams((prevParams) => {
37 | const hasChanges = Object.keys(newParams).some(
38 | (key) => prevParams[key] !== newParams[key],
39 | );
40 | return hasChanges ? newParams : prevParams;
41 | });
42 | }, [defaultParams]);
43 |
44 | const setURLQueryParams = useCallback(
45 | (newParams: Partial) => {
46 | const searchParams = searchParamsRef.current;
47 |
48 | Object.entries(newParams).forEach(([key, value]) => {
49 | if (value == undefined || value === defaultParams[key]) {
50 | searchParams.delete(key);
51 | } else {
52 | searchParams.set(key, value);
53 | }
54 | });
55 |
56 | const newUrl =
57 | searchParams.toString() === ""
58 | ? window.location.pathname
59 | : `${window.location.pathname}?${searchParams.toString()}`;
60 | window.history.pushState({}, "", newUrl);
61 |
62 | updateQueryParamsFromURL();
63 | },
64 | [defaultParams, updateQueryParamsFromURL],
65 | );
66 |
67 | useEffect(() => {
68 | const handlePopState = () => {
69 | searchParamsRef.current = new URLSearchParams(window.location.search);
70 | updateQueryParamsFromURL();
71 | };
72 |
73 | window.addEventListener("popstate", handlePopState);
74 |
75 | return () => {
76 | window.removeEventListener("popstate", handlePopState);
77 | };
78 | }, [updateQueryParamsFromURL]);
79 |
80 | return { queryParams, setURLQueryParams };
81 | }
82 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/hooks/useScripts.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | const generateUniqueId = () => {
4 | return `script-${Math.random().toString(36).substring(2, 11)}`;
5 | };
6 |
7 | const loadScript = (script: { url: string; id: string }) => {
8 | const { url, id } = script;
9 |
10 | return new Promise((resolve, reject) => {
11 | const scriptElement = document.createElement("script");
12 |
13 | scriptElement.src = url;
14 | scriptElement.async = true;
15 | scriptElement.type = "text/javascript";
16 | scriptElement.setAttribute("data-id", id);
17 | scriptElement.onload = () => resolve(scriptElement);
18 | scriptElement.onerror = () => reject(new Error(`Failed to load script: ${url}`));
19 |
20 | document.body.appendChild(scriptElement);
21 | });
22 | };
23 |
24 | export const useScripts = (scripts: string[]) => {
25 | const [areScriptsLoaded, setAreScriptsLoaded] = useState(false);
26 |
27 | useEffect(() => {
28 | let isCleanup = false;
29 |
30 | const scriptsWithIds = scripts.map((script) => ({
31 | url: script,
32 | id: generateUniqueId(),
33 | }));
34 |
35 | const cleanUp = () => {
36 | isCleanup = true;
37 | scriptsWithIds.forEach(({ id }) => {
38 | const scriptElement = document.querySelector(`script[data-id="${id}"]`);
39 | if (scriptElement) {
40 | document.body.removeChild(scriptElement);
41 | }
42 | });
43 | };
44 |
45 | const loadAllScripts = async () => {
46 | try {
47 | for (const script of scriptsWithIds) {
48 | await loadScript(script);
49 | if (isCleanup) {
50 | cleanUp();
51 | break;
52 | }
53 | }
54 | if (!isCleanup) setAreScriptsLoaded(true);
55 | } catch (error) {
56 | // eslint-disable-next-line no-console
57 | console.error("Error loading scripts", error);
58 | }
59 | };
60 |
61 | void loadAllScripts();
62 |
63 | return () => cleanUp();
64 | }, [scripts]);
65 |
66 | return areScriptsLoaded;
67 | };
68 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/index.ts:
--------------------------------------------------------------------------------
1 | export { P2PVideoDemo } from "./components/P2PVideoDemo";
2 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/src/types.ts:
--------------------------------------------------------------------------------
1 | import { PLAYERS } from "./constants";
2 | import { CoreEventMap } from "p2p-media-loader-core";
3 |
4 | export type DownloadStats = {
5 | httpDownloaded: number;
6 | p2pDownloaded: number;
7 | p2pUploaded: number;
8 | };
9 |
10 | export type SvgDimensionsType = {
11 | width: number;
12 | height: number;
13 | };
14 |
15 | export type ChartsData = {
16 | seconds: number;
17 | } & DownloadStats;
18 |
19 | export type PlayerKey = keyof typeof PLAYERS;
20 | export type PlayerName = (typeof PLAYERS)[PlayerKey];
21 |
22 | export type PlayerProps = {
23 | streamUrl: string;
24 | announceTrackers: string[];
25 | swarmId?: string;
26 | } & Partial<
27 | Pick<
28 | CoreEventMap,
29 | "onPeerConnect" | "onChunkDownloaded" | "onChunkUploaded" | "onPeerClose"
30 | >
31 | >;
32 |
33 | export type PlayerEvents = Omit<
34 | PlayerProps,
35 | "streamUrl" | "announceTrackers" | "swarmId"
36 | >;
37 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "lib",
5 | "rootDir": "src",
6 | "tsBuildInfoFile": "./build/.tsbuildinfo",
7 | "jsx": "react-jsx"
8 | },
9 | "include": ["src/**/*"],
10 | "references": [{ "path": "./tsconfig.node.json" }]
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-demo/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler"
6 | },
7 | "include": ["eslint.config.js", "dummy.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/.editorconfig:
--------------------------------------------------------------------------------
1 | root = false
2 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | dist
4 | .gitignore
5 | README.md
6 | LICENSE
7 | package-lock.json
8 | pnpm-lock.yaml
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require("../../.prettierrc.common.cjs"),
3 | };
4 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/README.md:
--------------------------------------------------------------------------------
1 | ../../README.md
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { CommonConfig } from "../../eslint.common.config.js";
4 | import tsEslint from "typescript-eslint";
5 |
6 | export default tsEslint.config(...CommonConfig);
7 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-media-loader-hlsjs",
3 | "version": "2.1.0",
4 | "description": "P2P Media Loader hls.js integration",
5 | "license": "Apache-2.0",
6 | "author": "Novage",
7 | "homepage": "https://github.com/Novage/p2p-media-loader",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/Novage/p2p-media-loader",
11 | "directory": "packages/p2p-media-loader-hlsjs"
12 | },
13 | "keywords": [
14 | "p2p",
15 | "peer-to-peer",
16 | "hls",
17 | "webrtc",
18 | "video",
19 | "mse",
20 | "player",
21 | "torrent",
22 | "bittorrent",
23 | "webtorrent",
24 | "hlsjs",
25 | "ecdn",
26 | "cdn"
27 | ],
28 | "files": [
29 | "dist",
30 | "lib",
31 | "src"
32 | ],
33 | "exports": "./src/index.ts",
34 | "types": "./src/index.ts",
35 | "publishConfig": {
36 | "exports": "./lib/index.js",
37 | "types": "./lib/index.d.ts"
38 | },
39 | "sideEffects": false,
40 | "type": "module",
41 | "scripts": {
42 | "dev": "vite",
43 | "build": "rimraf lib build && pnpm build:es && pnpm build:esm && pnpm build:esm-min",
44 | "build:esm": "vite build --mode esm",
45 | "build:esm-min": "vite build --mode esm-min",
46 | "build:es": "tsc",
47 | "prettier": "prettier --write .",
48 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
49 | "clean": "rimraf lib dist build p2p-media-loader-hlsjs-*.tgz",
50 | "clean-with-modules": "rimraf node_modules && pnpm clean",
51 | "type-check": "npx tsc --noEmit"
52 | },
53 | "dependencies": {
54 | "p2p-media-loader-core": "workspace:*"
55 | },
56 | "devDependencies": {
57 | "@rollup/plugin-terser": "^0.4.4",
58 | "hls.js": "^1.5.20"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/engine-static.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HlsJsP2PEngine,
3 | PartialHlsJsP2PEngineConfig,
4 | HlsWithP2PInstance,
5 | HlsWithP2PConfig,
6 | } from "./engine.js";
7 |
8 | export function injectMixin<
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | HlsJsConstructor extends new (...args: any[]) => any,
11 | >(HlsJsClass: HlsJsConstructor) {
12 | return class HlsJsWithP2PClass extends HlsJsClass {
13 | #p2pEngine: HlsJsP2PEngine;
14 |
15 | get p2pEngine() {
16 | return this.#p2pEngine;
17 | }
18 |
19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
20 | constructor(...args: any[]) {
21 | const config = args[0] as
22 | | ({
23 | p2p?: PartialHlsJsP2PEngineConfig & {
24 | onHlsJsCreated?: (hls: InstanceType) => void;
25 | };
26 | } & Record)
27 | | undefined;
28 |
29 | const { p2p, ...hlsJsConfig } = config ?? {};
30 |
31 | const p2pEngine = new HlsJsP2PEngine(p2p);
32 |
33 | super({ ...hlsJsConfig, ...p2pEngine.getConfigForHlsJs() });
34 |
35 | p2pEngine.bindHls(this);
36 |
37 | this.#p2pEngine = p2pEngine;
38 | p2p?.onHlsJsCreated?.(this as InstanceType);
39 | }
40 | } as new (
41 | config?: HlsWithP2PConfig,
42 | ) => HlsWithP2PInstance>;
43 | }
44 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/fragment-loader.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | FragmentLoaderContext,
3 | HlsConfig,
4 | Loader,
5 | LoaderCallbacks,
6 | LoaderConfiguration,
7 | LoaderContext,
8 | LoaderStats,
9 | } from "hls.js";
10 | import * as Utils from "./utils.js";
11 | import { Core, SegmentResponse, CoreRequestError } from "p2p-media-loader-core";
12 |
13 | const DEFAULT_DOWNLOAD_LATENCY = 10;
14 |
15 | export class FragmentLoaderBase implements Loader {
16 | context!: FragmentLoaderContext;
17 | config!: LoaderConfiguration | null;
18 | stats: LoaderStats;
19 | #callbacks!: LoaderCallbacks | null;
20 | #createDefaultLoader: () => Loader;
21 | #defaultLoader?: Loader;
22 | #core: Core;
23 | #response?: SegmentResponse;
24 | #segmentId?: string;
25 |
26 | constructor(config: HlsConfig, core: Core) {
27 | this.#core = core;
28 | this.#createDefaultLoader = () => new config.loader(config);
29 | this.stats = {
30 | aborted: false,
31 | chunkCount: 0,
32 | loading: { start: 0, first: 0, end: 0 },
33 | buffering: { start: 0, first: 0, end: 0 },
34 | parsing: { start: 0, end: 0 },
35 | // set total and loaded to 1 to prevent hls.js
36 | // on progress loading monitoring in AbrController
37 | total: 1,
38 | loaded: 1,
39 | bwEstimate: 0,
40 | retry: 0,
41 | };
42 | }
43 |
44 | load(
45 | context: FragmentLoaderContext,
46 | config: LoaderConfiguration,
47 | callbacks: LoaderCallbacks,
48 | ) {
49 | this.context = context;
50 | this.config = config;
51 | this.#callbacks = callbacks;
52 | const { stats } = this;
53 |
54 | const { rangeStart: start, rangeEnd: end } = context;
55 | const byteRange = Utils.getByteRange(
56 | start,
57 | end !== undefined ? end - 1 : undefined,
58 | );
59 |
60 | this.#segmentId = Utils.getSegmentRuntimeId(context.url, byteRange);
61 | const isSegmentDownloadableByP2PCore = this.#core.isSegmentLoadable(
62 | this.#segmentId,
63 | );
64 |
65 | if (
66 | !this.#core.hasSegment(this.#segmentId) ||
67 | !isSegmentDownloadableByP2PCore
68 | ) {
69 | this.#defaultLoader = this.#createDefaultLoader();
70 | this.#defaultLoader.stats = this.stats;
71 | this.#defaultLoader.load(context, config, callbacks);
72 | return;
73 | }
74 |
75 | const onSuccess = (response: SegmentResponse) => {
76 | this.#response = response;
77 | const loadedBytes = this.#response.data.byteLength;
78 | stats.loading = getLoadingStat(
79 | this.#response.bandwidth,
80 | loadedBytes,
81 | performance.now(),
82 | );
83 | stats.total = loadedBytes;
84 | stats.loaded = loadedBytes;
85 |
86 | if (callbacks.onProgress) {
87 | callbacks.onProgress(
88 | this.stats,
89 | context,
90 | this.#response.data,
91 | undefined,
92 | );
93 | }
94 | callbacks.onSuccess(
95 | { data: this.#response.data, url: context.url },
96 | this.stats,
97 | context,
98 | undefined,
99 | );
100 | };
101 |
102 | const onError = (error: unknown) => {
103 | if (
104 | error instanceof CoreRequestError &&
105 | error.type === "aborted" &&
106 | this.stats.aborted
107 | ) {
108 | return;
109 | }
110 | this.#handleError(error);
111 | };
112 |
113 | void this.#core.loadSegment(this.#segmentId, { onSuccess, onError });
114 | }
115 |
116 | #handleError(thrownError: unknown) {
117 | const error = { code: 0, text: "" };
118 | if (
119 | thrownError instanceof CoreRequestError &&
120 | thrownError.type === "failed"
121 | ) {
122 | // error.code = thrownError.code;
123 | error.text = thrownError.message;
124 | } else if (thrownError instanceof Error) {
125 | error.text = thrownError.message;
126 | }
127 | this.#callbacks?.onError(error, this.context, null, this.stats);
128 | }
129 |
130 | #abortInternal() {
131 | if (!this.#response && this.#segmentId) {
132 | this.stats.aborted = true;
133 | this.#core.abortSegmentLoading(this.#segmentId);
134 | }
135 | }
136 |
137 | abort() {
138 | if (this.#defaultLoader) {
139 | this.#defaultLoader.abort();
140 | } else {
141 | this.#abortInternal();
142 | this.#callbacks?.onAbort?.(this.stats, this.context, {});
143 | }
144 | }
145 |
146 | destroy() {
147 | if (this.#defaultLoader) {
148 | this.#defaultLoader.destroy();
149 | } else {
150 | if (!this.stats.aborted) this.#abortInternal();
151 | this.#callbacks = null;
152 | this.config = null;
153 | }
154 | }
155 | }
156 |
157 | function getLoadingStat(
158 | targetBitrate: number,
159 | loadedBytes: number,
160 | loadingEndTime: number,
161 | ) {
162 | const timeForLoading = (loadedBytes * 8000) / targetBitrate;
163 | const first = loadingEndTime - timeForLoading;
164 | const start = first - DEFAULT_DOWNLOAD_LATENCY;
165 |
166 | return { start, first, end: loadingEndTime };
167 | }
168 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/index.ts:
--------------------------------------------------------------------------------
1 | export { HlsJsP2PEngine } from "./engine.js";
2 |
3 | export type {
4 | DynamicHlsJsP2PEngineConfig,
5 | HlsJsP2PEngineConfig,
6 | PartialHlsJsP2PEngineConfig,
7 | HlsWithP2PInstance,
8 | HlsWithP2PConfig,
9 | } from "./engine.js";
10 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/playlist-loader.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HlsConfig,
3 | Loader,
4 | LoaderCallbacks,
5 | LoaderConfiguration,
6 | LoaderContext,
7 | LoaderStats,
8 | PlaylistLoaderContext,
9 | } from "hls.js";
10 |
11 | export class PlaylistLoaderBase implements Loader {
12 | #defaultLoader: Loader;
13 | context: PlaylistLoaderContext;
14 | stats: LoaderStats;
15 |
16 | constructor(config: HlsConfig) {
17 | this.#defaultLoader = new config.loader(config);
18 | this.stats = this.#defaultLoader.stats;
19 | this.context = this.#defaultLoader.context as PlaylistLoaderContext;
20 | }
21 |
22 | load(
23 | context: LoaderContext,
24 | config: LoaderConfiguration,
25 | callbacks: LoaderCallbacks,
26 | ) {
27 | this.#defaultLoader.load(context, config, callbacks);
28 | }
29 |
30 | abort() {
31 | this.#defaultLoader.abort();
32 | }
33 |
34 | destroy() {
35 | this.#defaultLoader.destroy();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/segment-mananger.ts:
--------------------------------------------------------------------------------
1 | import * as Utils from "./utils.js";
2 | import type {
3 | ManifestLoadedData,
4 | LevelUpdatedData,
5 | AudioTrackLoadedData,
6 | } from "hls.js";
7 | import { Core, Segment } from "p2p-media-loader-core";
8 |
9 | export class SegmentManager {
10 | core: Core;
11 |
12 | constructor(core: Core) {
13 | this.core = core;
14 | }
15 |
16 | processMainManifest(data: ManifestLoadedData) {
17 | const { levels, audioTracks } = data;
18 | // in the case of audio only stream it is stored in levels
19 |
20 | for (const [index, level] of levels.entries()) {
21 | const { url } = level;
22 | this.core.addStreamIfNoneExists({
23 | runtimeId: Array.isArray(url) ? (url as string[])[0] : url,
24 | type: "main",
25 | index,
26 | });
27 | }
28 |
29 | for (const [index, track] of audioTracks.entries()) {
30 | const { url } = track;
31 | this.core.addStreamIfNoneExists({
32 | runtimeId: Array.isArray(url) ? (url as string[])[0] : url,
33 | type: "secondary",
34 | index,
35 | });
36 | }
37 | }
38 |
39 | updatePlaylist(data: LevelUpdatedData | AudioTrackLoadedData) {
40 | const {
41 | details: { url, fragments, live },
42 | } = data;
43 |
44 | const playlist = this.core.getStream(url);
45 | if (!playlist) return;
46 |
47 | const segmentToRemoveIds = new Set(playlist.segments.keys());
48 | const newSegments: Segment[] = [];
49 | fragments.forEach((fragment, index) => {
50 | const {
51 | url: responseUrl,
52 | byteRange: fragByteRange,
53 | sn,
54 | start: startTime,
55 | end: endTime,
56 | } = fragment;
57 | if (sn === "initSegment") return;
58 |
59 | const [start, end] = fragByteRange;
60 | const byteRange = Utils.getByteRange(
61 | start,
62 | end !== undefined ? end - 1 : undefined,
63 | );
64 | const runtimeId = Utils.getSegmentRuntimeId(responseUrl, byteRange);
65 | segmentToRemoveIds.delete(runtimeId);
66 |
67 | if (playlist.segments.has(runtimeId)) return;
68 | newSegments.push({
69 | runtimeId,
70 | url: responseUrl,
71 | externalId: live ? sn : index,
72 | byteRange,
73 | startTime,
74 | endTime,
75 | });
76 | });
77 |
78 | if (!newSegments.length && !segmentToRemoveIds.size) return;
79 | this.core.updateStream(url, newSegments, segmentToRemoveIds.values());
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { ByteRange } from "p2p-media-loader-core";
2 |
3 | export function getSegmentRuntimeId(
4 | segmentRequestUrl: string,
5 | byteRange?: ByteRange,
6 | ) {
7 | if (!byteRange) return segmentRequestUrl;
8 | return `${segmentRequestUrl}|${byteRange.start}-${byteRange.end}`;
9 | }
10 |
11 | export function getByteRange(
12 | rangeStart: number | undefined,
13 | rangeEnd: number | undefined,
14 | ): ByteRange | undefined {
15 | if (
16 | rangeStart !== undefined &&
17 | rangeEnd !== undefined &&
18 | rangeStart <= rangeEnd
19 | ) {
20 | return { start: rangeStart, end: rangeEnd };
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "lib",
5 | "rootDir": "src",
6 | "tsBuildInfoFile": "./build/.tsbuildinfo"
7 | },
8 | "include": ["src/**/*"],
9 | "references": [{ "path": "./tsconfig.node.json" }]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler"
6 | },
7 | "include": ["vite.config.ts", "eslint.config.js"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "entryPoints": ["src/index.ts"],
3 | "excludeExternals": true,
4 | "excludePrivate": true,
5 | "readme": "none",
6 | "sort": ["source-order"]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-hlsjs/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import type { UserConfig } from "vite";
3 | import terser from "@rollup/plugin-terser";
4 |
5 | const getESMConfig = ({ minify }: { minify: boolean }): UserConfig => {
6 | return {
7 | build: {
8 | emptyOutDir: false,
9 | minify: minify ? "esbuild" : false,
10 | sourcemap: true,
11 | lib: {
12 | name: "p2pml.hlsjs",
13 | fileName: (format) =>
14 | `p2p-media-loader-hlsjs.${format}${minify ? ".min" : ""}.js`,
15 | formats: ["es"],
16 | entry: "src/index.ts",
17 | },
18 | rollupOptions: {
19 | external: ["p2p-media-loader-core"],
20 | },
21 | },
22 | plugins: [
23 | minify
24 | ? terser({
25 | format: {
26 | comments: false,
27 | },
28 | })
29 | : undefined,
30 | ],
31 | };
32 | };
33 |
34 | export default defineConfig(({ mode }) => {
35 | switch (mode) {
36 | case "esm":
37 | return getESMConfig({ minify: false });
38 |
39 | case "esm-min":
40 | default:
41 | return getESMConfig({ minify: true });
42 | }
43 | });
44 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/.editorconfig:
--------------------------------------------------------------------------------
1 | root = false
2 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | dist
4 | .gitignore
5 | README.md
6 | LICENSE
7 | package-lock.json
8 | pnpm-lock.yaml
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require("../../.prettierrc.common.cjs"),
3 | };
4 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/README.md:
--------------------------------------------------------------------------------
1 | ../../README.md
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { CommonConfig } from "../../eslint.common.config.js";
4 | import tsEslint from "typescript-eslint";
5 |
6 | export default tsEslint.config(...CommonConfig);
7 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-media-loader-shaka",
3 | "version": "2.1.0",
4 | "description": "P2P Media Loader Shaka Player integration",
5 | "license": "Apache-2.0",
6 | "author": "Novage",
7 | "homepage": "https://github.com/Novage/p2p-media-loader",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/Novage/p2p-media-loader",
11 | "directory": "packages/p2p-media-loader-shaka"
12 | },
13 | "keywords": [
14 | "p2p",
15 | "peer-to-peer",
16 | "hls",
17 | "dash",
18 | "webrtc",
19 | "video",
20 | "mse",
21 | "player",
22 | "torrent",
23 | "bittorrent",
24 | "webtorrent",
25 | "shaka player",
26 | "ecdn",
27 | "cdn"
28 | ],
29 | "files": [
30 | "dist",
31 | "lib",
32 | "src"
33 | ],
34 | "exports": "./src/index.ts",
35 | "types": "./src/index.ts",
36 | "publishConfig": {
37 | "exports": "./lib/index.js",
38 | "types": "./lib/index.d.ts"
39 | },
40 | "sideEffects": false,
41 | "type": "module",
42 | "scripts": {
43 | "dev": "vite",
44 | "build": "rimraf lib build && pnpm build:es && pnpm build:esm && pnpm build:esm-min",
45 | "build:esm": "vite build --mode esm",
46 | "build:esm-min": "vite build --mode esm-min",
47 | "build:es": "tsc",
48 | "prettier": "prettier --write .",
49 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
50 | "clean": "rimraf lib dist build p2p-media-loader-shaka-*.tgz",
51 | "clean-with-modules": "rimraf node_modules && pnpm clean",
52 | "type-check": "npx tsc --noEmit"
53 | },
54 | "dependencies": {
55 | "p2p-media-loader-core": "workspace:*"
56 | },
57 | "devDependencies": {
58 | "@rollup/plugin-terser": "^0.4.4",
59 | "shaka-player": "^4.13.8"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/src/index.ts:
--------------------------------------------------------------------------------
1 | export { ShakaP2PEngine } from "./engine.js";
2 | export type {
3 | DynamicShakaP2PEngineConfig,
4 | ShakaP2PEngineConfig,
5 | PartialShakaEngineConfig,
6 | } from "./engine.js";
7 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/src/loading-handler.ts:
--------------------------------------------------------------------------------
1 | import * as Utils from "./stream-utils.js";
2 | import { StreamInfo, Shaka, Stream } from "./types.js";
3 | import {
4 | Core,
5 | CoreRequestError,
6 | SegmentResponse,
7 | EngineCallbacks,
8 | } from "p2p-media-loader-core";
9 |
10 | type LoadingHandlerParams = Parameters;
11 | type Response = shaka.extern.Response;
12 | type LoadingHandlerResult = shaka.extern.IAbortableOperation;
13 |
14 | export class Loader {
15 | private loadArgs!: LoadingHandlerParams;
16 |
17 | constructor(
18 | private readonly shaka: Shaka,
19 | private readonly core: Core,
20 | readonly streamInfo: StreamInfo,
21 | ) {}
22 |
23 | private defaultLoad() {
24 | const fetchPlugin = this.shaka.net.HttpFetchPlugin;
25 | return fetchPlugin.parse(...this.loadArgs);
26 | }
27 |
28 | load(...args: LoadingHandlerParams): LoadingHandlerResult {
29 | this.loadArgs = args;
30 | const { RequestType } = this.shaka.net.NetworkingEngine;
31 | const [url, request, requestType] = args;
32 | if (requestType === RequestType.SEGMENT) {
33 | return this.loadSegment(url, request);
34 | }
35 |
36 | const loading = this.defaultLoad() as LoadingHandlerResult;
37 | if (requestType === RequestType.MANIFEST) {
38 | void this.handleManifestLoading(loading.promise);
39 | }
40 | return loading;
41 | }
42 |
43 | private async handleManifestLoading(loadingPromise: Promise) {
44 | if (!this.streamInfo.manifestResponseUrl) {
45 | // loading main manifest either HLS or DASH
46 | const response = await loadingPromise;
47 | this.setManifestResponseUrl(response.uri);
48 | }
49 | }
50 |
51 | private loadSegment(
52 | segmentUrl: string,
53 | originalRequest: shaka.extern.Request,
54 | ): LoadingHandlerResult {
55 | const byteRangeString = originalRequest.headers.Range;
56 | const segmentRuntimeId = Utils.getSegmentRuntimeId(
57 | segmentUrl,
58 | byteRangeString,
59 | );
60 | const isSegmentDownloadableByP2PCore =
61 | this.core.isSegmentLoadable(segmentRuntimeId);
62 |
63 | if (
64 | !this.core.hasSegment(segmentRuntimeId) ||
65 | !isSegmentDownloadableByP2PCore
66 | ) {
67 | return this.defaultLoad() as LoadingHandlerResult;
68 | }
69 |
70 | const loadSegment = async (): Promise => {
71 | const { request, callbacks } = getSegmentRequest();
72 | void this.core.loadSegment(segmentRuntimeId, callbacks);
73 | try {
74 | const { data, bandwidth } = await request;
75 | return {
76 | data,
77 | headers: {},
78 | originalRequest,
79 | uri: segmentUrl,
80 | originalUri: segmentUrl,
81 | timeMs: getLoadingDurationBasedOnBandwidth(
82 | bandwidth,
83 | data.byteLength,
84 | ),
85 | };
86 | } catch (error) {
87 | // TODO: throw Shaka Errors
88 | if (error instanceof CoreRequestError) {
89 | const { Error: ShakaError } = this.shaka.util;
90 | if (error.type === "aborted") {
91 | throw new ShakaError(
92 | ShakaError.Severity.RECOVERABLE,
93 | ShakaError.Category.NETWORK,
94 | this.shaka.util.Error.Code.OPERATION_ABORTED,
95 | );
96 | }
97 | }
98 | throw error;
99 | }
100 | };
101 |
102 | return new this.shaka.util.AbortableOperation(loadSegment(), () => {
103 | this.core.abortSegmentLoading(segmentRuntimeId);
104 | return Promise.resolve();
105 | });
106 | }
107 |
108 | private setManifestResponseUrl(responseUrl: string) {
109 | this.streamInfo.manifestResponseUrl = responseUrl;
110 | this.core.setManifestResponseUrl(responseUrl);
111 | }
112 | }
113 |
114 | function getLoadingDurationBasedOnBandwidth(
115 | bandwidth: number,
116 | bytesLoaded: number,
117 | ) {
118 | const bits = bytesLoaded * 8;
119 | return Math.round(bits / bandwidth) * 1000;
120 | }
121 |
122 | function getSegmentRequest(): {
123 | callbacks: EngineCallbacks;
124 | request: Promise;
125 | } {
126 | let onSuccess: (value: SegmentResponse) => void;
127 | let onError: (reason?: unknown) => void;
128 | const request = new Promise((resolve, reject) => {
129 | onSuccess = resolve;
130 | onError = reject;
131 | });
132 |
133 | return {
134 | request,
135 | callbacks: {
136 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
137 | onSuccess: onSuccess!,
138 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
139 | onError: onError!,
140 | },
141 | };
142 | }
143 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/src/segment-manager.ts:
--------------------------------------------------------------------------------
1 | import * as Utils from "./stream-utils.js";
2 | import {
3 | HookedStream,
4 | StreamInfo,
5 | Stream,
6 | StreamWithReadonlySegments,
7 | } from "./types.js";
8 | import { Core, Segment, StreamType } from "p2p-media-loader-core";
9 |
10 | // The minimum time interval (in seconds) between segments to assign unique IDs.
11 | // If two segments in the same playlist start within a time frame shorter than this interval,
12 | // they risk being assigned the same ID.
13 | // Such overlapping IDs can lead to potential conflicts or issues in segment processing.
14 | const SEGMENT_ID_RESOLUTION_IN_SECONDS = 0.5;
15 |
16 | export class SegmentManager {
17 | private readonly core: Core;
18 | private streamInfo: Readonly;
19 |
20 | constructor(streamInfo: Readonly, core: Core) {
21 | this.core = core;
22 | this.streamInfo = streamInfo;
23 | }
24 |
25 | setStream(shakaStream: HookedStream, type: StreamType, index = -1) {
26 | this.core.addStreamIfNoneExists({
27 | runtimeId: shakaStream.id.toString(),
28 | type,
29 | index,
30 | shakaStream,
31 | });
32 | if (shakaStream.segmentIndex) this.updateStreamSegments(shakaStream);
33 | }
34 |
35 | updateStreamSegments(
36 | shakaStream: HookedStream,
37 | segmentReferences?: shaka.media.SegmentReference[],
38 | ) {
39 | const stream = this.core.getStream(shakaStream.id.toString());
40 | if (!stream) return;
41 |
42 | const { segmentIndex } = stream.shakaStream;
43 | if (!segmentReferences && segmentIndex) {
44 | try {
45 | segmentReferences = [...segmentIndex];
46 | } catch {
47 | return;
48 | }
49 | }
50 | if (!segmentReferences) return;
51 |
52 | if (this.streamInfo.protocol === "hls") {
53 | this.processHlsSegmentReferences(stream, segmentReferences);
54 | } else {
55 | this.processDashSegmentReferences(stream, segmentReferences);
56 | }
57 | }
58 |
59 | private processDashSegmentReferences(
60 | managerStream: StreamWithReadonlySegments,
61 | segmentReferences: shaka.media.SegmentReference[],
62 | ) {
63 | const staleSegmentsIds = new Set(managerStream.segments.keys());
64 | const newSegments: Segment[] = [];
65 | for (const reference of segmentReferences) {
66 | const externalId = Math.trunc(
67 | reference.getStartTime() / SEGMENT_ID_RESOLUTION_IN_SECONDS,
68 | );
69 |
70 | const runtimeId = Utils.getSegmentRuntimeIdFromReference(reference);
71 | if (!managerStream.segments.has(runtimeId)) {
72 | const segment = Utils.createSegment({
73 | segmentReference: reference,
74 | externalId,
75 | runtimeId,
76 | });
77 | newSegments.push(segment);
78 | }
79 | staleSegmentsIds.delete(runtimeId);
80 | }
81 |
82 | if (!newSegments.length && !staleSegmentsIds.size) return;
83 | this.core.updateStream(
84 | managerStream.runtimeId,
85 | newSegments,
86 | staleSegmentsIds.values(),
87 | );
88 | }
89 |
90 | private processHlsSegmentReferences(
91 | managerStream: StreamWithReadonlySegments,
92 | segmentReferences: shaka.media.SegmentReference[],
93 | ) {
94 | const { segments } = managerStream;
95 | const lastMediaSequence = Utils.getStreamLastMediaSequence(managerStream);
96 |
97 | const newSegments: Segment[] = [];
98 | if (segments.size === 0) {
99 | const firstReferenceMediaSequence =
100 | lastMediaSequence === undefined
101 | ? 0
102 | : lastMediaSequence - segmentReferences.length + 1;
103 |
104 | for (const [index, reference] of segmentReferences.entries()) {
105 | const segment = Utils.createSegment({
106 | segmentReference: reference,
107 | externalId: firstReferenceMediaSequence + index,
108 | });
109 | newSegments.push(segment);
110 | }
111 | this.core.updateStream(managerStream.runtimeId, newSegments);
112 | return;
113 | }
114 |
115 | if (!lastMediaSequence) return;
116 | let mediaSequence = lastMediaSequence;
117 |
118 | for (const reference of itemsBackwards(segmentReferences)) {
119 | const runtimeId = Utils.getSegmentRuntimeIdFromReference(reference);
120 | if (segments.has(runtimeId)) break;
121 | const segment = Utils.createSegment({
122 | runtimeId,
123 | segmentReference: reference,
124 | externalId: mediaSequence,
125 | });
126 | newSegments.push(segment);
127 | mediaSequence--;
128 | }
129 | newSegments.reverse();
130 |
131 | const staleSegmentIds: string[] = [];
132 | const countToDelete = newSegments.length;
133 | for (const segment of nSegmentsBackwards(segments, countToDelete)) {
134 | staleSegmentIds.push(segment.runtimeId);
135 | }
136 |
137 | if (!newSegments.length && !staleSegmentIds.length) return;
138 | this.core.updateStream(
139 | managerStream.runtimeId,
140 | newSegments,
141 | staleSegmentIds,
142 | );
143 | }
144 | }
145 |
146 | function* itemsBackwards(items: T[]) {
147 | for (let i = items.length - 1; i >= 0; i--) yield items[i];
148 | }
149 |
150 | function* nSegmentsBackwards(
151 | segments: ReadonlyMap,
152 | count: number,
153 | ) {
154 | let i = 0;
155 | for (const segment of segments.values()) {
156 | if (i >= count) break;
157 | yield segment;
158 | i++;
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/src/stream-utils.ts:
--------------------------------------------------------------------------------
1 | import { StreamWithReadonlySegments } from "./types.js";
2 | import { Segment, ByteRange } from "p2p-media-loader-core";
3 |
4 | export function createSegment({
5 | segmentReference,
6 | externalId,
7 | runtimeId,
8 | }: {
9 | segmentReference: shaka.media.SegmentReference;
10 | externalId: number;
11 | runtimeId?: string;
12 | }): Segment {
13 | const { byteRange, url, startTime, endTime } =
14 | getSegmentInfoFromReference(segmentReference);
15 | return {
16 | runtimeId: runtimeId ?? getSegmentRuntimeId(url, byteRange),
17 | externalId,
18 | byteRange,
19 | url,
20 | startTime,
21 | endTime,
22 | };
23 | }
24 |
25 | export function getSegmentRuntimeIdFromReference(
26 | segmentReference: shaka.media.SegmentReference,
27 | ) {
28 | const { url, byteRange } = getSegmentInfoFromReference(segmentReference);
29 | return getSegmentRuntimeId(url, byteRange);
30 | }
31 |
32 | export function getSegmentRuntimeId(
33 | url: string,
34 | byteRange?: ByteRange | string,
35 | ) {
36 | if (!byteRange) return url;
37 |
38 | const range: ByteRange | undefined =
39 | typeof byteRange === "string"
40 | ? getByteRangeFromHeaderString(byteRange)
41 | : byteRange;
42 |
43 | if (!range) return url;
44 | return `${url}|${range.start}-${range.end}`;
45 | }
46 |
47 | export function getByteRangeFromHeaderString(
48 | rangeStr: string | undefined,
49 | ): ByteRange | undefined {
50 | if (!rangeStr?.includes("bytes=")) return undefined;
51 |
52 | const range = rangeStr
53 | .split("=")[1]
54 | .split("-")
55 | .map((i) => parseInt(i));
56 | const [start, end] = range;
57 | return { start, end };
58 | }
59 |
60 | export function getSegmentInfoFromReference(
61 | segmentReference: shaka.media.SegmentReference,
62 | ) {
63 | const uris = segmentReference.getUris();
64 | const responseUrl = uris[1] ?? uris[0];
65 | const start = segmentReference.getStartByte();
66 | const end = segmentReference.getEndByte() ?? undefined;
67 | const startTime = segmentReference.getStartTime();
68 | const endTime = segmentReference.getEndTime();
69 |
70 | return {
71 | byteRange: end !== undefined ? { start, end } : undefined,
72 | url: responseUrl,
73 | startTime,
74 | endTime,
75 | };
76 | }
77 |
78 | export function getStreamLastMediaSequence(stream: StreamWithReadonlySegments) {
79 | const { shakaStream } = stream;
80 | const map = shakaStream.mediaSequenceTimeMap;
81 | if (!map) return;
82 |
83 | const firstMediaSequence = map.keys().next().value;
84 | if (firstMediaSequence === undefined) return;
85 | return firstMediaSequence + map.size - 1;
86 | }
87 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/src/types.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Stream as CoreStream,
3 | Core,
4 | SegmentWithStream,
5 | } from "p2p-media-loader-core";
6 | import { SegmentManager } from "./segment-manager.js";
7 |
8 | export type StreamProtocol = "hls" | "dash";
9 |
10 | export type StreamInfo = {
11 | protocol?: StreamProtocol;
12 | manifestResponseUrl?: string;
13 | };
14 |
15 | export type HookedStream = shaka.extern.Stream & {
16 | streamUrl?: string;
17 | mediaSequenceTimeMap?: Map;
18 | isSegmentIndexAlreadyRead?: boolean;
19 | };
20 |
21 | export type Stream = CoreStream & {
22 | shakaStream: HookedStream;
23 | };
24 |
25 | export type Shaka = typeof window.shaka;
26 |
27 | export type P2PMLShakaData = {
28 | player: shaka.Player;
29 | core: Core;
30 | shaka: Shaka;
31 | streamInfo: StreamInfo;
32 | segmentManager: SegmentManager;
33 | };
34 |
35 | export type HookedRequest = shaka.extern.Request & {
36 | p2pml?: P2PMLShakaData;
37 | };
38 |
39 | export type HookedNetworkingEngine = shaka.net.NetworkingEngine & {
40 | p2pml?: P2PMLShakaData;
41 | };
42 |
43 | export type StreamWithReadonlySegments = Stream & {
44 | segments: ReadonlyMap;
45 | };
46 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "lib",
5 | "rootDir": "src",
6 | "tsBuildInfoFile": "./build/.tsbuildinfo"
7 | },
8 | "include": ["src/**/*"],
9 | "references": [{ "path": "./tsconfig.node.json" }]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler"
6 | },
7 | "include": ["vite.config.ts", "eslint.config.js"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "entryPoints": ["src/index.ts"],
3 | "excludeExternals": true,
4 | "excludePrivate": true,
5 | "readme": "none",
6 | "sort": ["source-order"]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/p2p-media-loader-shaka/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import type { UserConfig } from "vite";
3 | import terser from "@rollup/plugin-terser";
4 |
5 | const getESMConfig = ({ minify }: { minify: boolean }): UserConfig => {
6 | return {
7 | build: {
8 | emptyOutDir: false,
9 | minify: minify ? "esbuild" : false,
10 | sourcemap: true,
11 | lib: {
12 | name: "p2pml.shaka",
13 | fileName: (format) =>
14 | `p2p-media-loader-shaka.${format}${minify ? ".min" : ""}.js`,
15 | formats: ["es"],
16 | entry: "src/index.ts",
17 | },
18 | rollupOptions: {
19 | external: ["p2p-media-loader-core"],
20 | },
21 | },
22 | plugins: [
23 | minify
24 | ? terser({
25 | format: {
26 | comments: false,
27 | },
28 | })
29 | : undefined,
30 | ,
31 | ],
32 | };
33 | };
34 |
35 | export default defineConfig(({ mode }) => {
36 | switch (mode) {
37 | case "esm":
38 | return getESMConfig({ minify: false });
39 |
40 | case "esm-min":
41 | default:
42 | return getESMConfig({ minify: true });
43 | }
44 | });
45 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "packages/*"
3 | - "demo"
4 |
--------------------------------------------------------------------------------
/scripts/update-versions.js:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import { fileURLToPath } from "url";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = path.dirname(__filename);
7 |
8 | const packages = [
9 | "../packages/p2p-media-loader-core/package.json",
10 | "../packages/p2p-media-loader-hlsjs/package.json",
11 | "../packages/p2p-media-loader-shaka/package.json",
12 | "../packages/p2p-media-loader-demo/package.json",
13 | ];
14 |
15 | const versionFile = "../packages/p2p-media-loader-core/src/utils/version.ts";
16 |
17 | function updateVersionFile(versionFilePath, newVersion) {
18 | const fullPath = path.resolve(__dirname, versionFilePath);
19 | let fileContent = fs.readFileSync(fullPath, "utf8");
20 |
21 | fileContent = fileContent.replace(/"(.*?)"/, `"${newVersion}"`);
22 |
23 | fs.writeFileSync(fullPath, fileContent);
24 | }
25 |
26 | function updateVersion(packagePath, newVersion) {
27 | const fullPath = path.resolve(__dirname, packagePath);
28 | const packageJson = JSON.parse(fs.readFileSync(fullPath, "utf8"));
29 | const updatedPackageJson = { ...packageJson, version: newVersion };
30 | fs.writeFileSync(
31 | fullPath,
32 | JSON.stringify(updatedPackageJson, null, 2) + "\n",
33 | );
34 | }
35 |
36 | function main() {
37 | const newVersion = process.env.TAG;
38 | if (!newVersion) {
39 | console.error(
40 | "ERROR: No version provided. Please set the TAG environment variable.",
41 | );
42 | process.exit(1);
43 | }
44 |
45 | packages.forEach((packagePath) => {
46 | updateVersion(packagePath, newVersion);
47 | console.log(`Updated ${packagePath} to version ${newVersion}`);
48 | });
49 |
50 | updateVersionFile(versionFile, newVersion);
51 | console.log(`Updated ${versionFile} to version ${newVersion}`);
52 | }
53 |
54 | main();
55 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "moduleResolution": "Bundler",
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "skipLibCheck": true,
9 | "allowImportingTsExtensions": false,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": false,
13 | "declaration": true,
14 | "sourceMap": true,
15 | "strict": true,
16 | "strictNullChecks": true,
17 | "strictFunctionTypes": true,
18 | "noImplicitAny": true,
19 | "noUnusedLocals": false,
20 | "noUnusedParameters": false,
21 | "noFallthroughCasesInSwitch": true,
22 | "composite": true,
23 | "allowSyntheticDefaultImports": true
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "P2P Media Loader Documentation",
3 | "entryPoints": ["packages/*"],
4 | "plugin": ["typedoc-material-theme"],
5 | "exclude": ["packages/p2p-media-loader-demo"],
6 | "entryPointStrategy": "packages",
7 | "customCss": "typedoc/styles.css",
8 | "readme": "api_documentation.md"
9 | }
10 |
--------------------------------------------------------------------------------
/typedoc/styles.css:
--------------------------------------------------------------------------------
1 | .tsd-filter-visibility {
2 | display: none;
3 | }
4 |
5 | .tsd-typography ul {
6 | margin: 0.2em 0;
7 | }
8 |
9 | .tsd-page-title h2 {
10 | font-size: 32px;
11 | }
12 |
13 | code,
14 | pre {
15 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace !important;
16 | }
17 |
18 | html * {
19 | scrollbar-width: auto;
20 | scrollbar-color: auto;
21 | }
22 |
23 | .tsd-navigation::-webkit-scrollbar {
24 | background-color: #ffffff;
25 | border-radius: 10px;
26 | }
27 |
28 | .tsd-navigation::-webkit-scrollbar-thumb {
29 | background-color: #f6e0bb;
30 | border-radius: 10px;
31 | }
32 |
33 | .tsd-navigation::-webkit-scrollbar-button {
34 | display: none !important;
35 | }
36 |
37 | #tsd-theme {
38 | width: 100%;
39 | padding: 8px;
40 | border: 1px solid #ccc;
41 | border-radius: 5px;
42 | background-color: white;
43 | color: #333;
44 | cursor: pointer;
45 | }
46 |
--------------------------------------------------------------------------------