├── .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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 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 |
81 |