├── .eslintignore ├── screenshot.png ├── commitlint.config.js ├── .husky └── commit-msg ├── .prettierrc.json ├── .prettierignore ├── .gitignore ├── tsconfig.json ├── .eslintrc ├── src ├── adapters │ ├── Adapter.ts │ ├── WHPPAdapter.ts │ ├── AdapterFactory.ts │ ├── EyevinnAdapter.ts │ └── WHEPAdapter.ts └── index.ts ├── .github └── workflows │ ├── lint.yml │ ├── pretty.yml │ ├── typescript.yml │ ├── publish.yml │ └── demo.yml ├── docs └── whep-source.md ├── LICENSE ├── package.json ├── demo ├── index.html └── demo.ts └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-demo -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eyevinn/webrtc-player/HEAD/screenshot.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-demo 3 | umd_dist 4 | node_modules 5 | .parcel-cache 6 | .DS_Store 7 | lerna-debug.log 8 | .vscode/settings.json 9 | .vscode/extensions.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-demo 3 | umd_dist 4 | node_modules 5 | .parcel-cache 6 | .DS_Store 7 | lerna-debug.log 8 | .vscode/settings.json 9 | .vscode/extensions.json 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "sourceMap": true, 6 | "lib": ["ES2015", "dom"], 7 | "esModuleInterop": true, 8 | "strict": true 9 | }, 10 | "exclude": ["node_modules", "dist", "dist-demo"] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "plugins": ["@typescript-eslint", "prettier"], 14 | "rules": { 15 | "prettier/prettier": "error" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/adapters/Adapter.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_CONNECT_TIMEOUT = 2000; 2 | 3 | export interface AdapterConnectOptions { 4 | timeout: number; 5 | } 6 | 7 | export interface Adapter { 8 | enableDebug(): void; 9 | getPeer(): RTCPeerConnection | undefined; 10 | resetPeer(newPeer: RTCPeerConnection): void; 11 | connect(opts?: AdapterConnectOptions): Promise; 12 | disconnect(): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: [pull_request] 3 | 4 | jobs: 5 | lint: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Eslint 17 | run: npm run lint 18 | -------------------------------------------------------------------------------- /.github/workflows/pretty.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | on: [pull_request] 3 | 4 | jobs: 5 | pretty: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Prettier 17 | run: npm run pretty 18 | -------------------------------------------------------------------------------- /.github/workflows/typescript.yml: -------------------------------------------------------------------------------- 1 | name: Type Check 2 | on: [pull_request] 3 | 4 | jobs: 5 | ts: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Type Check 17 | run: npm run typecheck 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 18 16 | registry-url: https://registry.npmjs.org/ 17 | - run: | 18 | npm install 19 | npm run build 20 | npm publish --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 23 | -------------------------------------------------------------------------------- /docs/whep-source.md: -------------------------------------------------------------------------------- 1 | # WHEP test source 2 | 3 | Example on how to generate a local WHEP test source. 4 | 5 | First download and install the application `srt-whep`: 6 | 7 | ``` 8 | cargo install srt_whep 9 | ``` 10 | 11 | Generate an SRT test stream: 12 | 13 | ``` 14 | docker run --rm --name=testsrc -d -p 5678:1234/udp eyevinntechnology/testsrc 15 | ``` 16 | 17 | Run `srt-whep` to provide a WHEP version of the SRT stream 18 | 19 | ``` 20 | srt-whep -i 127.0.0.1:5678 -o 0.0.0.0:8888 -p 8000 -s caller 21 | ``` 22 | 23 | Then a local WHEP test source is available at `http://localhost:8000/channel` 24 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to demo site 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 18 16 | registry-url: https://registry.npmjs.org/ 17 | - name: build site 18 | run: | 19 | npm install 20 | npm run build:demo 21 | env: 22 | BROADCASTER_URL: 'https://broadcaster.lab.sto.eyevinn.technology:8443/broadcaster' 23 | ICE_SERVERS: ${{ secrets.DEMO_ICE_SERVERS }} 24 | - name: deploy 25 | uses: jakejarvis/s3-sync-action@v0.5.1 26 | with: 27 | args: --follow-symlinks --delete 28 | env: 29 | AWS_S3_BUCKET: 'origin-web-webrtc' 30 | AWS_ACCESS_KEY_ID: ${{ secrets.S3_AWS_ACCESS_KEY_ID }} 31 | AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_AWS_SECRET_ACCESS_KEY }} 32 | AWS_REGION: 'eu-north-1' 33 | SOURCE_DIR: 'dist-demo/' 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022 Eyevinn Technology 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /src/adapters/WHPPAdapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter, AdapterConnectOptions } from './Adapter'; 2 | import { WHPPClient } from '@eyevinn/whpp-client'; 3 | 4 | export class WHPPAdapter implements Adapter { 5 | private client: WHPPClient | undefined = undefined; 6 | private localPeer: RTCPeerConnection | undefined = undefined; 7 | private channelUrl: URL; 8 | private debug = false; 9 | 10 | constructor( 11 | peer: RTCPeerConnection, 12 | channelUrl: URL, 13 | onError: (error: string) => void 14 | ) { 15 | this.channelUrl = channelUrl; 16 | this.resetPeer(peer); 17 | } 18 | 19 | enableDebug() { 20 | this.debug = true; 21 | } 22 | 23 | resetPeer(newPeer: RTCPeerConnection) { 24 | this.localPeer = newPeer; 25 | } 26 | 27 | getPeer(): RTCPeerConnection | undefined { 28 | return this.localPeer; 29 | } 30 | 31 | async connect(opts?: AdapterConnectOptions) { 32 | if (this.localPeer) { 33 | this.client = new WHPPClient(this.localPeer, this.channelUrl, { 34 | debug: this.debug 35 | }); 36 | await this.client.connect(); 37 | } 38 | } 39 | 40 | async disconnect() { 41 | return; 42 | } 43 | 44 | private log(...args: any[]) { 45 | if (this.debug) { 46 | console.log('WebRTC-player', ...args); 47 | } 48 | } 49 | 50 | private error(...args: any[]) { 51 | console.error('WebRTC-player', ...args); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/adapters/AdapterFactory.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from './Adapter'; 2 | import { WHPPAdapter } from './WHPPAdapter'; 3 | import { EyevinnAdapter } from './EyevinnAdapter'; 4 | import { WHEPAdapter } from './WHEPAdapter'; 5 | import { MediaConstraints } from '../index'; 6 | 7 | export interface AdapterFactoryFunction { 8 | ( 9 | peer: RTCPeerConnection, 10 | channelUrl: URL, 11 | onError: (error: string) => void, 12 | mediaConstraints: MediaConstraints, 13 | authKey: string | undefined 14 | ): Adapter; 15 | } 16 | 17 | interface AdapterMap { 18 | [type: string]: AdapterFactoryFunction; 19 | } 20 | 21 | const WHPPAdapterFactory: AdapterFactoryFunction = ( 22 | peer, 23 | channelUrl, 24 | onError, 25 | mediaConstraints, 26 | authKey 27 | ) => { 28 | return new WHPPAdapter(peer, channelUrl, onError); 29 | }; 30 | 31 | const EyevinnAdapterFactory: AdapterFactoryFunction = ( 32 | peer, 33 | channelUrl, 34 | onError, 35 | mediaConstraints, 36 | authKey 37 | ) => { 38 | return new EyevinnAdapter(peer, channelUrl, onError); 39 | }; 40 | 41 | const WHEPAdapterFactory: AdapterFactoryFunction = ( 42 | peer, 43 | channelUrl, 44 | onError, 45 | mediaConstraints, 46 | authKey 47 | ) => { 48 | return new WHEPAdapter(peer, channelUrl, onError, mediaConstraints, authKey); 49 | }; 50 | 51 | const adapters: AdapterMap = { 52 | 'se.eyevinn.whpp': WHPPAdapterFactory, 53 | 'se.eyevinn.webrtc': EyevinnAdapterFactory, 54 | whep: WHEPAdapterFactory 55 | }; 56 | 57 | export function AdapterFactory( 58 | type: string, 59 | peer: RTCPeerConnection, 60 | channelUrl: URL, 61 | onError: (error: string) => void, 62 | mediaConstraints: MediaConstraints, 63 | authKey?: string 64 | ): Adapter { 65 | return adapters[type](peer, channelUrl, onError, mediaConstraints, authKey); 66 | } 67 | 68 | export function ListAvailableAdapters(): string[] { 69 | return Object.keys(adapters); 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eyevinn/webrtc-player", 3 | "version": "0.13.1", 4 | "description": "Media Server independent WebRTC player", 5 | "homepage": "https://webcast.eyevinn.technology", 6 | "bugs": "https://github.com/Eyevinn/webrtc-player/issues", 7 | "source": "src/index.ts", 8 | "main": "dist/main.js", 9 | "types": "dist/types.d.ts", 10 | "targets": { 11 | "main": { 12 | "distDir": "./dist", 13 | "engines": { 14 | "browsers": "> 0.5%, last 2 versions, not dead" 15 | } 16 | }, 17 | "demo": { 18 | "source": "demo/index.html", 19 | "distDir": "./dist-demo", 20 | "isLibrary": false, 21 | "engines": { 22 | "browsers": "> 0.5%, last 2 versions, not dead" 23 | } 24 | } 25 | }, 26 | "scripts": { 27 | "dev": "parcel --target demo --dist-dir dist-demo --host 0.0.0.0 --port 2345 --no-cache", 28 | "build": "parcel build --target main --target types", 29 | "build:demo": "parcel build --target demo", 30 | "postversion": "git push && git push --tags", 31 | "prepare": "husky install", 32 | "lint": "eslint .", 33 | "pretty": "prettier --check --ignore-unknown .", 34 | "pretty:format": "prettier --write --ignore-unknown .", 35 | "typecheck": "tsc --noEmit -p tsconfig.json", 36 | "test": "echo \"Error: no test specified\" && exit 1" 37 | }, 38 | "engines": { 39 | "node": ">=18.15.0" 40 | }, 41 | "author": "Eyevinn Technology AB ", 42 | "contributors": [ 43 | "Jonas Birmé (Eyevinn Technology AB)" 44 | ], 45 | "keywords": [ 46 | "webrtc", 47 | "player" 48 | ], 49 | "license": "MIT", 50 | "repository": { 51 | "type": "git", 52 | "url": "git+https://github.com/Eyevinn/webrtc-player.git" 53 | }, 54 | "devDependencies": { 55 | "@parcel/packager-ts": "^2.8.3", 56 | "@parcel/transformer-typescript-types": "^2.8.3", 57 | "@types/events": "^3.0.0", 58 | "@types/node": "^17.0.33", 59 | "parcel": "^2.8.3", 60 | "prettier": "2.8.4", 61 | "typescript": "^4.6.3", 62 | "@commitlint/cli": "^17.4.2", 63 | "@commitlint/config-conventional": "^17.4.2", 64 | "@typescript-eslint/eslint-plugin": "^5.51.0", 65 | "@typescript-eslint/parser": "^5.51.0", 66 | "eslint": "^8.33.0", 67 | "eslint-config-prettier": "^8.6.0", 68 | "eslint-plugin-prettier": "^4.2.1", 69 | "husky": "^8.0.3" 70 | }, 71 | "dependencies": { 72 | "@eyevinn/csai-manager": "^0.1.2", 73 | "@eyevinn/whpp-client": "^0.1.2", 74 | "events": "^3.3.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Media Server independent WebRTC player 5 | 9 | 10 | 50 | 51 | 52 |
53 |

Media Server independent WebRTC player

54 |
55 |
56 |

Available adapters:

57 |
58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 71 |
72 |
73 | 74 |
75 |
76 | 77 | Atomic Wall clock provided by 79 | time.is 81 | 82 | 87 |
88 |
89 | 90 | Client Time 91 |
92 |
93 |
94 | 95 | 96 | 97 |
98 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/adapters/EyevinnAdapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter, AdapterConnectOptions } from './Adapter'; 2 | 3 | const DEFAULT_CONNECT_TIMEOUT = 2000; 4 | 5 | export class EyevinnAdapter implements Adapter { 6 | private localPeer: RTCPeerConnection | undefined; 7 | private channelUrl: URL; 8 | private debug: boolean; 9 | private iceGatheringTimeout: any; 10 | private waitingForCandidates = false; 11 | private resourceUrl: URL | undefined = undefined; 12 | 13 | constructor( 14 | peer: RTCPeerConnection, 15 | channelUrl: URL, 16 | onError: (error: string) => void 17 | ) { 18 | this.channelUrl = channelUrl; 19 | this.debug = true; 20 | this.resetPeer(peer); 21 | } 22 | 23 | enableDebug() { 24 | this.debug = true; 25 | } 26 | 27 | resetPeer(newPeer: RTCPeerConnection) { 28 | this.localPeer = newPeer; 29 | this.localPeer.onicegatheringstatechange = 30 | this.onIceGatheringStateChange.bind(this); 31 | this.localPeer.oniceconnectionstatechange = 32 | this.onIceConnectionStateChange.bind(this); 33 | this.localPeer.onicecandidateerror = this.onIceCandidateError.bind(this); 34 | this.localPeer.onicecandidate = this.onIceCandidate.bind(this); 35 | } 36 | 37 | getPeer(): RTCPeerConnection | undefined { 38 | return this.localPeer; 39 | } 40 | 41 | async connect(opts?: AdapterConnectOptions) { 42 | if (!this.localPeer) { 43 | this.log('Local RTC peer not initialized'); 44 | return; 45 | } 46 | 47 | this.localPeer.addTransceiver('video', { direction: 'recvonly' }); 48 | this.localPeer.addTransceiver('audio', { direction: 'recvonly' }); 49 | 50 | const offer = await this.localPeer.createOffer({ 51 | offerToReceiveAudio: true, 52 | offerToReceiveVideo: true 53 | }); 54 | this.localPeer.setLocalDescription(offer); 55 | 56 | this.waitingForCandidates = true; 57 | this.iceGatheringTimeout = setTimeout( 58 | this.onIceGatheringTimeout.bind(this), 59 | (opts && opts.timeout) || DEFAULT_CONNECT_TIMEOUT 60 | ); 61 | } 62 | 63 | async disconnect() { 64 | return; 65 | } 66 | 67 | private log(...args: any[]) { 68 | if (this.debug) { 69 | console.log('WebRTC-player', ...args); 70 | } 71 | } 72 | 73 | private error(...args: any[]) { 74 | console.error('WebRTC-player', ...args); 75 | } 76 | 77 | private onIceGatheringStateChange(event: Event) { 78 | if (!this.localPeer) { 79 | this.log('Local RTC peer not initialized'); 80 | return; 81 | } 82 | 83 | this.log('IceGatheringState', this.localPeer.iceGatheringState); 84 | 85 | if ( 86 | this.localPeer.iceGatheringState !== 'complete' || 87 | !this.waitingForCandidates 88 | ) { 89 | return; 90 | } 91 | 92 | this.onDoneWaitingForCandidates(); 93 | } 94 | 95 | private onIceConnectionStateChange() { 96 | if (!this.localPeer) { 97 | this.log('Local RTC peer not initialized'); 98 | return; 99 | } 100 | 101 | this.log('IceConnectionState', this.localPeer.iceConnectionState); 102 | 103 | if (this.localPeer.iceConnectionState === 'failed') { 104 | this.localPeer.close(); 105 | } 106 | } 107 | 108 | private async onIceCandidate(event: Event) { 109 | if (event.type !== 'icecandidate') { 110 | return; 111 | } 112 | const candidateEvent = event; 113 | const candidate: RTCIceCandidate | null = candidateEvent.candidate; 114 | if (!candidate) { 115 | return; 116 | } 117 | 118 | this.log('IceCandidate', candidate.candidate); 119 | } 120 | 121 | private onIceCandidateError(e: Event) { 122 | this.log('IceCandidateError', e); 123 | } 124 | 125 | private onIceGatheringTimeout() { 126 | this.log('IceGatheringTimeout'); 127 | 128 | if (!this.waitingForCandidates) { 129 | return; 130 | } 131 | 132 | this.onDoneWaitingForCandidates(); 133 | } 134 | 135 | private async onDoneWaitingForCandidates() { 136 | if (!this.localPeer) { 137 | this.log('Local RTC peer not initialized'); 138 | return; 139 | } 140 | 141 | this.waitingForCandidates = false; 142 | clearTimeout(this.iceGatheringTimeout); 143 | 144 | const response = await fetch(this.channelUrl.toString(), { 145 | method: 'POST', 146 | headers: { 147 | 'Content-Type': 'application/json' 148 | }, 149 | body: JSON.stringify({ sdp: this.localPeer.localDescription?.sdp }) 150 | }); 151 | if (response.ok) { 152 | const { sdp } = await response.json(); 153 | this.localPeer.setRemoteDescription({ type: 'answer', sdp: sdp }); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /demo/demo.ts: -------------------------------------------------------------------------------- 1 | import { WebRTCPlayer, ListAvailableAdapters } from '../src/index'; 2 | 3 | interface PacketsLost { 4 | [type: string]: number; 5 | } 6 | 7 | const BROADCASTER_URL = 8 | process.env.BROADCASTER_URL || 9 | 'https://broadcaster.lab.sto.eyevinn.technology:8443/broadcaster'; 10 | const WHEP_URL = 11 | process.env.WHEP_URL || 12 | 'https://srtwhep.lab.sto.eyevinn.technology:8443/channel'; 13 | 14 | async function getChannels(broadcasterUrl: string) { 15 | const response = await fetch(broadcasterUrl + '/channel'); 16 | if (response.ok) { 17 | const channels = await response.json(); 18 | return channels; 19 | } 20 | return []; 21 | } 22 | 23 | let clientTimeMsElement: HTMLSpanElement | null; 24 | 25 | function pad(v: number, n: number) { 26 | let r; 27 | for (r = v.toString(); r.length < n; r = 0 + r); 28 | return r; 29 | } 30 | 31 | function updateClientClock() { 32 | const now = new Date(); 33 | const [h, m, s, ms] = [ 34 | now.getHours(), 35 | now.getMinutes(), 36 | now.getSeconds(), 37 | now.getMilliseconds() 38 | ]; 39 | const ts = `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}.${pad(ms, 3)}`; 40 | if (clientTimeMsElement) { 41 | clientTimeMsElement.innerHTML = ts; 42 | } 43 | } 44 | 45 | window.addEventListener('DOMContentLoaded', async () => { 46 | const input = document.querySelector('#channelUrl'); 47 | const video = document.querySelector('video'); 48 | const inputContainer = document.querySelector('#input'); 49 | const adapterContainer = document.querySelector('#adapters'); 50 | const inputPrerollUrl = 51 | document.querySelector('#prerollUrl'); 52 | 53 | if (!input || !inputContainer || !adapterContainer || !inputPrerollUrl) { 54 | return; 55 | } 56 | 57 | const searchParams = new URL(window.location.href).searchParams; 58 | const type = searchParams.get('type') || 'whep'; 59 | 60 | if (type === 'se.eyevinn.whpp' || type === 'se.eyevinn.webrtc') { 61 | const channels = await getChannels(BROADCASTER_URL); 62 | if (channels.length > 0) { 63 | input.value = channels[0].resource; 64 | } 65 | inputContainer.style.display = 'block'; 66 | } else { 67 | if (type === 'whep') { 68 | input.value = WHEP_URL; 69 | } 70 | inputContainer.style.display = 'block'; 71 | } 72 | 73 | ListAvailableAdapters().forEach((adapterType) => { 74 | const btn = document.createElement('button'); 75 | btn.textContent = adapterType; 76 | btn.onclick = () => { 77 | const url = new URL(window.location.href); 78 | url.searchParams.set('type', adapterType); 79 | window.open(url, '_self'); 80 | }; 81 | adapterContainer.appendChild(btn); 82 | }); 83 | 84 | let iceServers: RTCIceServer[]; 85 | 86 | if (process.env.ICE_SERVERS) { 87 | iceServers = []; 88 | process.env.ICE_SERVERS.split(',').forEach((server) => { 89 | // turn::@turn.eyevinn.technology:3478 90 | const m = server.match(/^turn:(\S+):(\S+)@(\S+):(\d+)/); 91 | if (m) { 92 | const [_, username, credential, host, port] = m; 93 | iceServers.push({ 94 | urls: 'turn:' + host + ':' + port, 95 | username: username, 96 | credential: credential 97 | }); 98 | } 99 | }); 100 | } 101 | 102 | let player: WebRTCPlayer; 103 | 104 | const playButton = document.querySelector('#play'); 105 | playButton?.addEventListener('click', async () => { 106 | const channelUrl = input.value; 107 | const vmapUrlElem = document.querySelector('#preroll'); 108 | const vmapUrl = 109 | vmapUrlElem && vmapUrlElem.checked ? inputPrerollUrl.value : undefined; 110 | if (video) { 111 | player = new WebRTCPlayer({ 112 | video: video, 113 | type: type, 114 | iceServers: iceServers, 115 | debug: true, 116 | vmapUrl: vmapUrl, 117 | statsTypeFilter: '^candidate-*|^inbound-rtp' 118 | }); 119 | } 120 | 121 | const packetsLost: PacketsLost = { video: 0, audio: 0 }; 122 | 123 | player.on('stats:candidate-pair', (report) => { 124 | const currentRTTElem = 125 | document.querySelector('#stats-current-rtt'); 126 | const incomingBitrateElem = document.querySelector( 127 | '#stats-incoming-bitrate' 128 | ); 129 | if (report.nominated && currentRTTElem) { 130 | currentRTTElem.innerHTML = `RTT: ${ 131 | report.currentRoundTripTime * 1000 132 | }ms`; 133 | if (report.availableIncomingBitrate && incomingBitrateElem) { 134 | incomingBitrateElem.innerHTML = `Bitrate: ${Math.round( 135 | report.availableIncomingBitrate / 1000 136 | )}kbps`; 137 | } 138 | } 139 | }); 140 | player.on('stats:inbound-rtp', (report) => { 141 | if (report.kind === 'video' || report.kind === 'audio') { 142 | const packetLossElem = 143 | document.querySelector('#stats-packetloss'); 144 | packetsLost[report.kind] = report.packetsLost; 145 | if (packetLossElem) { 146 | packetLossElem.innerHTML = `Packets Lost: A=${packetsLost.audio},V=${packetsLost.video}`; 147 | } 148 | } 149 | }); 150 | 151 | await player.load(new URL(channelUrl)); 152 | }); 153 | 154 | const stopButton = document.querySelector('#stop'); 155 | stopButton?.addEventListener('click', async () => { 156 | await player.unload(); 157 | }); 158 | 159 | clientTimeMsElement = document.querySelector('#localTimeMs'); 160 | window.setInterval(updateClientClock, 1); 161 | }); 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Media Server independent WebRTC player 2 | 3 | As the SDP Offer/Answer exchange is WebRTC media server specific this WebRTC player is designed to be extended with Media Server specific adapters. You can either use one of the included media server adapters or build your own custom adapter. 4 | 5 | Contributions are welcome, see below for more information. 6 | 7 | [Online Demo](https://webrtc.player.eyevinn.technology/) 8 | 9 | ![Screenshot of demo application](screenshot.png) 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install @eyevinn/webrtc-player 15 | ``` 16 | 17 | To run the demo application run: 18 | 19 | ``` 20 | npm run dev 21 | ``` 22 | 23 | To provide a custom list of STUN/TURN servers to use. 24 | 25 | ``` 26 | ICE_SERVERS=turn::@turn.eyevinn.technology:3478 npm run dev 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```javascript 32 | import { WebRTCPlayer } from '@eyevinn/webrtc-player'; 33 | 34 | const video = document.querySelector('video'); 35 | const player = new WebRTCPlayer({ 36 | video: video, 37 | type: 'whep', 38 | statsTypeFilter: '^candidate-*|^inbound-rtp' 39 | }); 40 | await player.load(new URL(channelUrl)); 41 | player.unmute(); 42 | 43 | player.on('no-media', () => { 44 | console.log('media timeout occured'); 45 | }); 46 | player.on('media-recovered', () => { 47 | console.log('media recovered'); 48 | }); 49 | 50 | // Subscribe for RTC stats: `stats:${RTCStatsType}` 51 | player.on('stats:inbound-rtp', (report) => { 52 | if (report.kind === 'video') { 53 | console.log(report); 54 | } 55 | }); 56 | ``` 57 | 58 | ## Options 59 | 60 | ```javascript 61 | { 62 | video: HTMLVideoElement; 63 | iceServers: RTCIceServer[]; // ICE server config 64 | type: string; // type of adapter (see below for a list of included adapters below) 65 | adapterFactory: AdapterFactoryFunction; // provide a custom adapter factory when adapter type is "custom" 66 | vmapUrl?: string; // url to endpoint to obtain VMAP XML (ads) 67 | statsTypeFilter?: string; // regexp to match what RTC stats events will be emitted 68 | timeoutThreshold?: number; // timeout in ms until no-media event is emitted (default 30000 ms) 69 | mediaConstraints?: { 70 | audioOnly?: boolean, // sets the "audio-only" playback mode (default: false) 71 | videoOnly?: boolean // sets the "video-only" playback mode (default: false) 72 | } 73 | } 74 | ``` 75 | 76 | ## Adapters 77 | 78 | As SDP exchange is WebRTC media server specific this player includes adapters for various types of WebRTC media servers. 79 | 80 | ### `se.eyevinn.whpp` 81 | 82 | Compatible with WebRTC media servers that implements the [WebRTC HTTP Playback Protocol](https://github.com/Eyevinn/webrtc-http-playback-protocol). 83 | 84 | ### `whep` 85 | 86 | Compatible with WebRTC media servers that implements the [WebRTC HTTP Egress Protocol](https://www.ietf.org/id/draft-murillo-whep-00.html). Instructions on how to generate a WHEP test stream [here](docs/whep-source.md). 87 | 88 | ### `se.eyevinn.webrtc` 89 | 90 | Compatible with WebRTC media servers in [Eyevinn WHIP](https://github.com/Eyevinn/whip) project. Implements the following SDP exchange protocol: 91 | 92 | 1. WebRTC player (client) creates an SDP offer. 93 | 2. Client awaits ICE candidate selection to be completed. 94 | 3. Sends an updated local SDP in a JSON `{ sdp: }` to the server using HTTP POST to the specified `channelUrl`. 95 | 4. Server responds with a JSON `{ sdp: } ` containing the remote SDP. 96 | 97 | ### Custom Adapter 98 | 99 | To provide a custom adapter, implement the interface `Adapter`. 100 | 101 | ```javascript 102 | import { WebRTCPlayer, Adapter } from "@eyevinn/webrtc-player"; 103 | 104 | class CustomAdapter implements Adapter { 105 | private debug: boolean; 106 | private localPeer: RTCPeerConnection; 107 | private channelUrl: URL; 108 | 109 | constructor(peer: RTCPeerConnection, channelUrl: URL) { 110 | this.debug = false; 111 | this.localPeer = peer; 112 | this.channelUrl = channelUrl; 113 | } 114 | 115 | // Called when debug logs should be enabled 116 | enableDebug() { 117 | this.debug = true; 118 | } 119 | 120 | // Should return the RTCPeerConnection owned by this Adapter 121 | getPeer() : RTCPeerConnection { 122 | return this.localPeer; 123 | } 124 | 125 | // Implement the Adapter signalling here, starting the SDP negotiation flow. 126 | connect(opts?: AdapterConnectOptions) { 127 | } 128 | } 129 | ``` 130 | 131 | Then provide a factory function that will create a new instance of your adapter. 132 | 133 | ```javascript 134 | const video = document.querySelector('video'); 135 | const player = new WebRTCPlayer({ 136 | video: video, 137 | type: 'custom', 138 | adapterFactory: (peer: RTCPeerConnection, channelUrl: URL) => { 139 | return new CustomAdapter(peer, channelUrl); 140 | } 141 | }); 142 | ``` 143 | 144 | ## Contribution 145 | 146 | We would be super happy for contribution to this project in the form adapters for specific WebRTC media servers and of course bugfixes and improvements. Write an issue with a description together with a Pull Request. 147 | 148 | # Support 149 | 150 | Join our [community on Slack](http://slack.streamingtech.se) where you can post any questions regarding any of our open source projects. Eyevinn's consulting business can also offer you: 151 | 152 | - Further development of this component 153 | - Customization and integration of this component into your platform 154 | - Support and maintenance agreement 155 | 156 | Contact [sales@eyevinn.se](mailto:sales@eyevinn.se) if you are interested. 157 | 158 | # About Eyevinn Technology 159 | 160 | Eyevinn Technology is an independent consultant firm specialized in video and streaming. Independent in a way that we are not commercially tied to any platform or technology vendor. 161 | 162 | At Eyevinn, every software developer consultant has a dedicated budget reserved for open source development and contribution to the open source community. This give us room for innovation, team building and personal competence development. And also gives us as a company a way to contribute back to the open source community. 163 | 164 | Want to know more about Eyevinn and how it is to work here. Contact us at work@eyevinn.se! 165 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from './adapters/Adapter'; 2 | import { 3 | AdapterFactory, 4 | AdapterFactoryFunction 5 | } from './adapters/AdapterFactory'; 6 | import { EventEmitter } from 'events'; 7 | import { CSAIManager } from '@eyevinn/csai-manager'; 8 | 9 | export { ListAvailableAdapters } from './adapters/AdapterFactory'; 10 | 11 | enum Message { 12 | NO_MEDIA = 'no-media', 13 | MEDIA_RECOVERED = 'media-recovered', 14 | PEER_CONNECTION_FAILED = 'peer-connection-failed', 15 | PEER_CONNECTION_CONNECTED = 'peer-connection-connected', 16 | INITIAL_CONNECTION_FAILED = 'initial-connection-failed', 17 | CONNECT_ERROR = 'connect-error', 18 | PLAYER_MUTED = 'player-muted', 19 | PLAYER_UNMUTED = 'player-unmuted' 20 | } 21 | 22 | export interface MediaConstraints { 23 | audioOnly?: boolean; 24 | videoOnly?: boolean; 25 | } 26 | 27 | const MediaConstraintsDefaults: MediaConstraints = { 28 | audioOnly: false, 29 | videoOnly: false 30 | }; 31 | 32 | interface WebRTCPlayerOptions { 33 | video: HTMLVideoElement; 34 | type: string; 35 | adapterFactory?: AdapterFactoryFunction; 36 | iceServers?: RTCIceServer[]; 37 | debug?: boolean; 38 | vmapUrl?: string; 39 | statsTypeFilter?: string; // regexp 40 | detectTimeout?: boolean; 41 | timeoutThreshold?: number; 42 | mediaConstraints?: MediaConstraints; 43 | } 44 | 45 | const RECONNECT_ATTEMPTS = 2; 46 | 47 | export class WebRTCPlayer extends EventEmitter { 48 | private videoElement: HTMLVideoElement; 49 | private peer: RTCPeerConnection = {}; 50 | private adapterType: string; 51 | private adapterFactory: AdapterFactoryFunction | undefined = undefined; 52 | private iceServers: RTCIceServer[]; 53 | private debug: boolean; 54 | private channelUrl: URL = {}; 55 | private authKey?: string = undefined; 56 | private reconnectAttemptsLeft: number = RECONNECT_ATTEMPTS; 57 | private csaiManager?: CSAIManager; 58 | private adapter: Adapter = {}; 59 | private statsInterval: ReturnType | undefined; 60 | private statsTypeFilter: string | undefined = undefined; 61 | private msStatsInterval = 5000; 62 | private mediaTimeoutOccured = false; 63 | private mediaTimeoutThreshold = 30000; 64 | private timeoutThresholdCounter = 0; 65 | private bytesReceived = 0; 66 | private mediaConstraints: MediaConstraints; 67 | 68 | constructor(opts: WebRTCPlayerOptions) { 69 | super(); 70 | this.mediaConstraints = { 71 | ...MediaConstraintsDefaults, 72 | ...opts.mediaConstraints 73 | }; 74 | this.videoElement = opts.video; 75 | this.adapterType = opts.type; 76 | this.adapterFactory = opts.adapterFactory; 77 | this.statsTypeFilter = opts.statsTypeFilter; 78 | this.mediaTimeoutThreshold = 79 | opts.timeoutThreshold ?? this.mediaTimeoutThreshold; 80 | 81 | this.iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; 82 | if (opts.iceServers) { 83 | this.iceServers = opts.iceServers; 84 | } 85 | this.debug = !!opts.debug; 86 | if (opts.vmapUrl) { 87 | this.csaiManager = new CSAIManager({ 88 | contentVideoElement: this.videoElement, 89 | vmapUrl: opts.vmapUrl, 90 | isLive: true, 91 | autoplay: true 92 | }); 93 | this.videoElement.addEventListener('ended', () => { 94 | if (this.csaiManager) { 95 | this.csaiManager.destroy(); 96 | } 97 | }); 98 | } 99 | this.videoElement.addEventListener('volumechange', () => { 100 | if (this.videoElement.muted) { 101 | this.emit(Message.PLAYER_MUTED); 102 | } else { 103 | this.emit(Message.PLAYER_UNMUTED); 104 | } 105 | }); 106 | } 107 | 108 | async load(channelUrl: URL, authKey: string | undefined = undefined) { 109 | this.channelUrl = channelUrl; 110 | this.authKey = authKey; 111 | this.connect(); 112 | } 113 | 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | private log(...args: any[]) { 116 | if (this.debug) { 117 | console.log('WebRTC-player', ...args); 118 | } 119 | } 120 | 121 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 122 | private error(...args: any[]) { 123 | console.error('WebRTC-player', ...args); 124 | } 125 | 126 | private async onConnectionStateChange() { 127 | if (this.peer.connectionState === 'failed') { 128 | this.emit(Message.PEER_CONNECTION_FAILED); 129 | this.peer && this.peer.close(); 130 | 131 | if (this.reconnectAttemptsLeft <= 0) { 132 | this.error('Connection failed, reconnecting failed'); 133 | return; 134 | } 135 | 136 | this.log( 137 | `Connection failed, recreating peer connection, attempts left ${this.reconnectAttemptsLeft}` 138 | ); 139 | await this.connect(); 140 | this.reconnectAttemptsLeft--; 141 | } else if (this.peer.connectionState === 'connected') { 142 | this.log('Connected'); 143 | this.emit(Message.PEER_CONNECTION_CONNECTED); 144 | this.reconnectAttemptsLeft = RECONNECT_ATTEMPTS; 145 | } 146 | } 147 | 148 | private onErrorHandler(error: string) { 149 | this.log(`onError=${error}`); 150 | switch (error) { 151 | case 'reconnectneeded': 152 | this.peer && this.peer.close(); 153 | this.videoElement.srcObject = null; 154 | this.setupPeer(); 155 | this.adapter.resetPeer(this.peer); 156 | this.adapter.connect(); 157 | break; 158 | case 'connectionfailed': 159 | this.peer && this.peer.close(); 160 | this.videoElement.srcObject = null; 161 | this.emit(Message.INITIAL_CONNECTION_FAILED); 162 | break; 163 | case 'connecterror': 164 | this.peer && this.peer.close(); 165 | this.adapter.resetPeer(this.peer); 166 | this.emit(Message.CONNECT_ERROR); 167 | break; 168 | } 169 | } 170 | 171 | private async onConnectionStats() { 172 | if (this.peer && this.statsTypeFilter) { 173 | let bytesReceivedBlock = 0; 174 | const stats = await this.peer.getStats(null); 175 | 176 | stats.forEach((report) => { 177 | if (report.type.match(this.statsTypeFilter)) { 178 | this.emit(`stats:${report.type}`, report); 179 | } 180 | 181 | //inbound-rtp attribute bytesReceived from stats report will contain the total number of bytes received for this SSRC. 182 | //In this case there are several SSRCs. They are all added together in each onConnectionStats iteration and compared to their value during the previous iteration. 183 | if (report.type.match('inbound-rtp')) { 184 | bytesReceivedBlock += report.bytesReceived; 185 | } 186 | }); 187 | 188 | if (bytesReceivedBlock <= this.bytesReceived) { 189 | this.timeoutThresholdCounter += this.msStatsInterval; 190 | 191 | if ( 192 | this.mediaTimeoutOccured === false && 193 | this.timeoutThresholdCounter >= this.mediaTimeoutThreshold 194 | ) { 195 | this.emit(Message.NO_MEDIA); 196 | this.mediaTimeoutOccured = true; 197 | } 198 | } else { 199 | this.bytesReceived = bytesReceivedBlock; 200 | this.timeoutThresholdCounter = 0; 201 | 202 | if (this.mediaTimeoutOccured == true) { 203 | this.emit(Message.MEDIA_RECOVERED); 204 | this.mediaTimeoutOccured = false; 205 | } 206 | } 207 | } 208 | } 209 | 210 | private setupPeer() { 211 | this.peer = new RTCPeerConnection({ iceServers: this.iceServers }); 212 | this.peer.onconnectionstatechange = this.onConnectionStateChange.bind(this); 213 | this.peer.ontrack = this.onTrack.bind(this); 214 | } 215 | 216 | private onTrack(event: RTCTrackEvent) { 217 | for (const stream of event.streams) { 218 | if (stream.id === 'feedbackvideomslabel') { 219 | continue; 220 | } 221 | 222 | console.log( 223 | 'Set video element remote stream to ' + stream.id, 224 | ' audio ' + 225 | stream.getAudioTracks().length + 226 | ' video ' + 227 | stream.getVideoTracks().length 228 | ); 229 | 230 | // Create a new MediaStream if we don't have one 231 | if (!this.videoElement.srcObject) { 232 | this.videoElement.srcObject = new MediaStream(); 233 | } 234 | 235 | // We might have one stream of both audio and video, or separate streams for audio and video 236 | for (const track of stream.getTracks()) { 237 | (this.videoElement.srcObject as MediaStream).addTrack(track); 238 | } 239 | } 240 | } 241 | 242 | private async connect() { 243 | this.setupPeer(); 244 | 245 | if (this.adapterType !== 'custom') { 246 | this.adapter = AdapterFactory( 247 | this.adapterType, 248 | this.peer, 249 | this.channelUrl, 250 | this.onErrorHandler.bind(this), 251 | this.mediaConstraints, 252 | this.authKey 253 | ); 254 | } else if (this.adapterFactory) { 255 | this.adapter = this.adapterFactory( 256 | this.peer, 257 | this.channelUrl, 258 | this.onErrorHandler.bind(this), 259 | this.mediaConstraints, 260 | this.authKey 261 | ); 262 | } 263 | if (!this.adapter) { 264 | throw new Error(`Failed to create adapter (${this.adapterType})`); 265 | } 266 | 267 | if (this.debug) { 268 | this.adapter.enableDebug(); 269 | } 270 | 271 | this.statsInterval = setInterval( 272 | this.onConnectionStats.bind(this), 273 | this.msStatsInterval 274 | ); 275 | try { 276 | await this.adapter.connect(); 277 | } catch (error) { 278 | console.error(error); 279 | this.stop(); 280 | } 281 | } 282 | 283 | mute() { 284 | this.videoElement.muted = true; 285 | } 286 | 287 | unmute() { 288 | this.videoElement.muted = false; 289 | } 290 | 291 | async unload() { 292 | await this.adapter.disconnect(); 293 | this.stop(); 294 | } 295 | 296 | stop() { 297 | clearInterval(this.statsInterval); 298 | this.peer.close(); 299 | this.videoElement.srcObject = null; 300 | this.videoElement.load(); 301 | } 302 | 303 | destroy() { 304 | this.stop(); 305 | this.removeAllListeners(); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/adapters/WHEPAdapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter, AdapterConnectOptions } from './Adapter'; 2 | import { MediaConstraints } from '../index'; 3 | 4 | const DEFAULT_CONNECT_TIMEOUT = 2000; 5 | 6 | export enum WHEPType { 7 | Client, 8 | Server 9 | } 10 | 11 | export class WHEPAdapter implements Adapter { 12 | private localPeer: RTCPeerConnection | undefined; 13 | private channelUrl: URL; 14 | private authKey?: string; 15 | private debug = false; 16 | private whepType: WHEPType; 17 | private waitingForCandidates = false; 18 | private iceGatheringTimeout: ReturnType | undefined; 19 | private resource: string | null = null; 20 | private onErrorHandler: (error: string) => void; 21 | private audio: boolean; 22 | private video: boolean; 23 | private mediaConstraints: MediaConstraints; 24 | 25 | constructor( 26 | peer: RTCPeerConnection, 27 | channelUrl: URL, 28 | onError: (error: string) => void, 29 | mediaConstraints: MediaConstraints, 30 | authKey: string | undefined 31 | ) { 32 | this.mediaConstraints = mediaConstraints; 33 | this.channelUrl = channelUrl; 34 | if (typeof this.channelUrl === 'string') { 35 | throw new Error( 36 | `channelUrl parameter expected to be an URL not a string` 37 | ); 38 | } 39 | this.whepType = WHEPType.Client; 40 | this.authKey = authKey; 41 | 42 | this.onErrorHandler = onError; 43 | this.audio = !this.mediaConstraints.videoOnly; 44 | this.video = !this.mediaConstraints.audioOnly; 45 | this.resetPeer(peer); 46 | } 47 | 48 | enableDebug() { 49 | this.debug = true; 50 | } 51 | 52 | resetPeer(newPeer: RTCPeerConnection) { 53 | this.localPeer = newPeer; 54 | this.localPeer.onicegatheringstatechange = 55 | this.onIceGatheringStateChange.bind(this); 56 | this.localPeer.onicecandidate = this.onIceCandidate.bind(this); 57 | } 58 | 59 | getPeer(): RTCPeerConnection | undefined { 60 | return this.localPeer; 61 | } 62 | 63 | async connect(opts?: AdapterConnectOptions) { 64 | try { 65 | await this.initSdpExchange(); 66 | } catch (error) { 67 | console.error((error as Error).toString()); 68 | this.onErrorHandler('connecterror'); 69 | } 70 | } 71 | 72 | async disconnect() { 73 | if (this.resource) { 74 | this.log(`Disconnecting by removing resource ${this.resource}`); 75 | const headers: {Authorization?: string} = {}; 76 | this.authKey && (headers['Authorization'] = this.authKey); 77 | const response = await fetch(this.resource, { 78 | method: 'DELETE', 79 | headers, 80 | }); 81 | if (response.ok) { 82 | this.log(`Successfully removed resource`); 83 | } 84 | } 85 | } 86 | 87 | private async initSdpExchange() { 88 | clearTimeout(this.iceGatheringTimeout); 89 | 90 | if (this.localPeer && this.whepType === WHEPType.Client) { 91 | if (this.video) 92 | this.localPeer.addTransceiver('video', { direction: 'recvonly' }); 93 | if (this.audio) 94 | this.localPeer.addTransceiver('audio', { direction: 'recvonly' }); 95 | const offer = await this.localPeer.createOffer(); 96 | 97 | // To add NACK in offer we have to add it manually see https://bugs.chromium.org/p/webrtc/issues/detail?id=4543 for details 98 | if (offer.sdp) { 99 | const opusCodecId = offer.sdp.match(/a=rtpmap:(\d+) opus\/48000\/2/); 100 | 101 | if (opusCodecId !== null) { 102 | offer.sdp = offer.sdp.replace( 103 | 'opus/48000/2\r\n', 104 | 'opus/48000/2\r\na=rtcp-fb:' + opusCodecId[1] + ' nack\r\n' 105 | ); 106 | } 107 | } 108 | 109 | await this.localPeer.setLocalDescription(offer); 110 | this.waitingForCandidates = true; 111 | this.iceGatheringTimeout = setTimeout( 112 | this.onIceGatheringTimeout.bind(this), 113 | DEFAULT_CONNECT_TIMEOUT 114 | ); 115 | } else { 116 | if (this.localPeer) { 117 | const offer = await this.requestOffer(); 118 | await this.localPeer.setRemoteDescription({ 119 | type: 'offer', 120 | sdp: offer 121 | }); 122 | const answer = await this.localPeer.createAnswer(); 123 | try { 124 | await this.localPeer.setLocalDescription(answer); 125 | this.waitingForCandidates = true; 126 | this.iceGatheringTimeout = setTimeout( 127 | this.onIceGatheringTimeout.bind(this), 128 | DEFAULT_CONNECT_TIMEOUT 129 | ); 130 | } catch (error) { 131 | this.log(answer.sdp); 132 | throw error; 133 | } 134 | } 135 | } 136 | } 137 | 138 | private async onIceCandidate(event: Event) { 139 | if (event.type !== 'icecandidate') { 140 | return; 141 | } 142 | const candidateEvent = event; 143 | const candidate: RTCIceCandidate | null = candidateEvent.candidate; 144 | if (!candidate) { 145 | return; 146 | } 147 | 148 | this.log(candidate.candidate); 149 | } 150 | 151 | private onIceGatheringStateChange(event: Event) { 152 | if (this.localPeer) { 153 | this.log('IceGatheringState', this.localPeer.iceGatheringState); 154 | if ( 155 | this.localPeer.iceGatheringState !== 'complete' || 156 | !this.waitingForCandidates 157 | ) { 158 | return; 159 | } 160 | 161 | this.onDoneWaitingForCandidates(); 162 | } 163 | } 164 | 165 | private onIceGatheringTimeout() { 166 | this.log('IceGatheringTimeout'); 167 | 168 | if (!this.waitingForCandidates) { 169 | return; 170 | } 171 | 172 | this.onDoneWaitingForCandidates(); 173 | } 174 | 175 | private async onDoneWaitingForCandidates() { 176 | this.waitingForCandidates = false; 177 | clearTimeout(this.iceGatheringTimeout); 178 | 179 | if (this.whepType === WHEPType.Client) { 180 | await this.sendOffer(); 181 | } else { 182 | await this.sendAnswer(); 183 | } 184 | } 185 | 186 | private getResouceUrlFromHeaders(headers: Headers): string | null { 187 | if (headers.get('Location') && headers.get('Location')?.match(/^\//)) { 188 | const resourceUrl = new URL( 189 | headers.get('Location')!, 190 | this.channelUrl.origin 191 | ); 192 | return resourceUrl.toString(); 193 | } else { 194 | return headers.get('Location'); 195 | } 196 | } 197 | 198 | private async requestOffer() { 199 | if (this.whepType === WHEPType.Server) { 200 | this.log(`Requesting offer from: ${this.channelUrl}`); 201 | const headers: {'Content-Type': string, Authorization?: string} = { 202 | 'Content-Type': 'application/sdp' 203 | }; 204 | this.authKey && (headers['Authorization'] = this.authKey); 205 | 206 | const response = await fetch(this.channelUrl.toString(), { 207 | method: 'POST', 208 | headers, 209 | body: '' 210 | }); 211 | if (response.ok) { 212 | this.resource = this.getResouceUrlFromHeaders(response.headers); 213 | this.log('WHEP Resource', this.resource); 214 | const offer = await response.text(); 215 | this.log('Received offer', offer); 216 | return offer; 217 | } else { 218 | const serverMessage = await response.text(); 219 | throw new Error(serverMessage); 220 | } 221 | } 222 | } 223 | 224 | private async sendAnswer() { 225 | if (!this.localPeer) { 226 | this.log('Local RTC peer not initialized'); 227 | return; 228 | } 229 | 230 | if (this.whepType === WHEPType.Server && this.resource) { 231 | const answer = this.localPeer.localDescription; 232 | if (answer) { 233 | const headers: {'Content-Type': string, Authorization?: string} = { 234 | 'Content-Type': 'application/sdp' 235 | }; 236 | this.authKey && (headers['Authorization'] = this.authKey); 237 | const response = await fetch(this.resource, { 238 | method: 'PATCH', 239 | headers, 240 | body: answer.sdp 241 | }); 242 | if (!response.ok) { 243 | this.error(`sendAnswer response: ${response.status}`); 244 | } 245 | } 246 | } 247 | } 248 | 249 | private async sendOffer() { 250 | if (!this.localPeer) { 251 | this.log('Local RTC peer not initialized'); 252 | return; 253 | } 254 | 255 | const offer = this.localPeer.localDescription; 256 | 257 | if (this.whepType === WHEPType.Client && offer) { 258 | this.log(`Sending offer to ${this.channelUrl}`); 259 | const headers: {'Content-Type': string, Authorization?: string} = { 260 | 'Content-Type': 'application/sdp' 261 | }; 262 | this.authKey && (headers['Authorization'] = this.authKey); 263 | const response = await fetch(this.channelUrl.toString(), { 264 | method: 'POST', 265 | headers, 266 | body: offer.sdp 267 | }); 268 | 269 | if (response.ok) { 270 | this.resource = this.getResouceUrlFromHeaders(response.headers); 271 | this.log('WHEP Resource', this.resource); 272 | const answer = await response.text(); 273 | await this.localPeer.setRemoteDescription({ 274 | type: 'answer', 275 | sdp: answer 276 | }); 277 | } else if (response.status === 400) { 278 | this.log(`server does not support client-offer, need to reconnect`); 279 | this.whepType = WHEPType.Server; 280 | this.onErrorHandler('reconnectneeded'); 281 | } else if ( 282 | response.status === 406 && 283 | this.audio && 284 | !this.mediaConstraints.audioOnly && 285 | !this.mediaConstraints.videoOnly 286 | ) { 287 | this.log( 288 | `maybe server does not support audio. Let's retry without audio` 289 | ); 290 | this.audio = false; 291 | this.video = true; 292 | this.onErrorHandler('reconnectneeded'); 293 | } else if ( 294 | response.status === 406 && 295 | this.video && 296 | !this.mediaConstraints.audioOnly && 297 | !this.mediaConstraints.videoOnly 298 | ) { 299 | this.log( 300 | `maybe server does not support video. Let's retry without video` 301 | ); 302 | this.audio = true; 303 | this.video = false; 304 | this.onErrorHandler('reconnectneeded'); 305 | } else { 306 | this.error(`sendAnswer response: ${response.status}`); 307 | this.onErrorHandler('connectionfailed'); 308 | } 309 | } 310 | } 311 | 312 | private log(...args: any[]) { 313 | if (this.debug) { 314 | console.log('WebRTC-player', ...args); 315 | } 316 | } 317 | 318 | private error(...args: any[]) { 319 | console.error('WebRTC-player', ...args); 320 | } 321 | } 322 | --------------------------------------------------------------------------------