diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70fed49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ + +/server/config.* +!/server/config.example.js +/server/public/ +/server/certs/ +/server/mediasoup_valgrind_* + +/.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d9c6ae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# CHANGELOG + +This is the changelog of the mediasoup demo application. To check the changes in **mediasoup** and **mediasoup-client** libraries check their corresponding projects. + + +## 2019-03-17 + +* Moving app and server to mediasoup v3. + + +## 2019-02-11 + +* Remove transport max bitrate artificial limitation. + + +## 2019-01-25 + +* Update mediasoup to 2.6.8. It fixes a [crash](https://github.com/versatica/mediasoup/issues/258) in the C++ `TcpConnection` class. +* Update mediasoup-client to 2.4.9. + + +## 2019-01-16 + +* Update mediasoup to 2.6.7. It fixes a [wrong destruction](https://github.com/versatica/mediasoup/commit/2b76b620b92c15e41fbb5677a326a90f0f365c7e) of Transport C++ instances producing a loop and CPU usage at 100%. + + +## 2019-01-15 + +* Update mediasoup to 2.6.6. It fixes an important [port leak bug](https://github.com/versatica/mediasoup/issues/259). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..523d315 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2015, Iñaki Baz Castillo , José Luis Millán + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md index 53437e7..5e35d07 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,160 @@ -# zzxy_chat_online +# mediasoup-demo v3 +A demo application of [mediasoup](https://mediasoup.org) **v3**. + +Try it online at https://v3demo.mediasoup.org + + +## Resources + +* mediasoup website and documentation: [mediasoup.org](https://mediasoup.org) +* mediasoup support forum: [mediasoup.discourse.group](https://mediasoup.discourse.group) + + +## Configuration via query parameters + +By adding query parameters into the URL you can set certain settings of the application: + +| Parameter | Type | Description | Default Value | +| ------------------ | ------- | -------------------- | ------------- | +| `roomId` | String | Id of the room | Autogenerated | +| `displayName` | String | Display name of your participant | Autogenerated | +| `handlerName` | String | Handler name of the mediasoup-client `Device` instance | Autodetected | +| `forceTcp` | Boolean | Force RTC (audio/video/datachannel) over TCP instead of UDP | `false` | +| `produce` | Boolean | Enable sending of audio/video | `true` | +| `consume` | Boolean | Enable reception of audio/video | `true` | +| `datachannel` | Boolean | Enable DataChannels | `true` | +| `forceVP8` | Boolean | Force VP8 codec for webcam and screen sharing | `false` | +| `forceH264` | Boolean | Force H264 codec for webcam and screen sharing | `false` | +| `forceVP9` | Boolean | Force VP9 codec for webcam and screen sharing | `false` | +| `enableWebcamLayers` | Boolean | Enable simulcast or SVC for webcam | `true` | +| `enableSharingLayers` | Boolean | Enable simulcast or SVC for screen sharing | `true` | +| `webcamScalabilityMode` | String | `scalabilityMode` for webcam | 'L1T3' for VP8/H264 (in each simulcast encoding), 'L3T3_KEY' for VP9 | +| `sharingScalabilityMode` | String | `scalabilityMode` for screen sharing | 'L1T3' for VP8/H264 (in each simulcast encoding), 'L3T3' for VP9 | +| `numSimulcastStreams` | Number | Number of streams for simulcast in webcam and screen sharing | 3 | +| `info` | Boolean | Display detailed information about media transmission | `false` | +| `faceDetection` | Boolean | Run face detection | `false` | +| `externalVideo` | Boolean | Send an external video instead of local webcam | `false` | +| `e2eKey` | String | Key for media E2E encryption/decryption (just works with some OPUS and VP8 codecs) | | +| `consumerReplicas` | Number | Create artificial replicas of yourself and receive their audio and video (not displayed in the UI) | 0 | + + +## Installation + +* Clone the project: + +```bash +$ git clone https://github.com/versatica/mediasoup-demo.git +$ cd mediasoup-demo +$ git checkout v3 +``` + +* Ensure you have installed the [dependencies](https://mediasoup.org/documentation/v3/mediasoup/installation/#requirements) required by mediasoup to build. + +* Set up the mediasoup-demo server: + +```bash +$ cd server +$ npm install +``` + +* Copy `config.example.js` as `config.js` and customize it for your scenario: + +```bash +$ cp config.example.js config.js +``` + +**NOTE:** To be perfectly clear, "customize it for your scenario" is not something "optional". If you don't set proper values in `config.js` the application **won't work**. You must configure a tls certificate. + +* Set up the mediasoup-demo browser app: + +```bash +$ cd app +# For node 16 +$ npm install +# For node 18, use legacy peer dependencies +$ npm install --legacy-peer-deps +``` + + +## Run it locally + +* Run the Node.js server application in a terminal: + +```bash +$ cd server +$ npm start +``` + +* In a different terminal build and run the browser application: + +```bash +$ cd app +$ npm start +``` + +If you configured a self-signed tls certificate, and receive wss: connection errors, open the wss: url with https: protocol to accept the cert and allow wss: connections in your browser. + +* Enjoy. + + +## Deploy it in a server + +* Globally install `gulp-cli` NPM module (may need `sudo`): + +```bash +$ npm install -g gulp-cli +``` + +* Build the production ready browser application: + +```bash +$ cd app +$ gulp dist +``` + +* Upload the entire `server` folder to your server and make your web server (Apache, Nginx, etc) expose the `server/public` folder. + +* Edit your `server/config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, etc). + +* Within your server, run the Node.js application by setting the `DEBUG` environment variable according to your needs ([more info](https://mediasoup.org/documentation/v3/mediasoup/debugging/)): + +```bash +$ DEBUG="*mediasoup* *ERROR* *WARN*" node server.js +``` +* If you wish to run it as daemon/service you can use [pm2](https://www.npmjs.com/package/pm2) process manager. Or you can dockerize it among other options. + +* The Node.js application exposes an interactive terminal. When running as daemon (in background) the host administrator can connect to it by entering into the `server` folder and running: + +```bash +$ npm run connect +``` + +## Run mediasoup server with Docker + +* Required environment variables: [server/DOCKER.md](server/DOCKER.md). +* Build the Docker image: [server/docker/build.sh](server/docker/build.sh). +* Run the Docker image: [server/docker/run.sh](server/docker/run.sh). + +``` +$ cd server +$ docker/build.sh +$ MEDIASOUP_ANNOUNCED_IP=192.168.1.34 ./docker/run.sh +``` + +#### Considerations for [config.js](server/config.example.js) + +* Make sure [https.listenIp](server/config.example.js#L20) is set to `0.0.0.0`. +* Make sure [TLS certificates](server/config.example.js#L24) reside in `server/certs` directory with names `fullchain.pem` and `privkey.pem`. +* The default mediasoup port range is just 2000-2020, which is not suitable for production. You should increase it, however you should then run the container in `network="host"` mode. + + +## Authors + +* Iñaki Baz Castillo [[website](https://inakibaz.me)|[github](https://github.com/ibc/)] +* José Luis Millán Villegas [[github](https://github.com/jmillan/)] + + +## License + +MIT diff --git a/aiortc/.eslintrc.js b/aiortc/.eslintrc.js new file mode 100644 index 0000000..428fc0d --- /dev/null +++ b/aiortc/.eslintrc.js @@ -0,0 +1,223 @@ +const eslintConfig = +{ + env : + { + es6 : true, + node : true + }, + plugins : [], + settings : {}, + parserOptions : + { + ecmaVersion : 2018, + sourceType : 'module', + ecmaFeatures : + { + impliedStrict : true + } + }, + rules : + { + 'array-bracket-spacing' : [ 2, 'always', + { + objectsInArrays : true, + arraysInArrays : true + } + ], + 'arrow-parens' : [ 2, 'always' ], + 'arrow-spacing' : 2, + 'block-spacing' : [ 2, 'always' ], + 'brace-style' : [ 2, 'allman', { allowSingleLine: true } ], + 'camelcase' : 2, + 'comma-dangle' : 2, + 'comma-spacing' : [ 2, { before: false, after: true } ], + 'comma-style' : 2, + 'computed-property-spacing' : 2, + 'constructor-super' : 2, + 'func-call-spacing' : 2, + 'generator-star-spacing' : 2, + 'guard-for-in' : 2, + 'indent' : [ 2, 'tab', { 'SwitchCase': 1 } ], + 'key-spacing' : [ 2, + { + singleLine : + { + beforeColon : false, + afterColon : true + }, + multiLine : + { + beforeColon : true, + afterColon : true, + align : 'colon' + } + } + ], + 'keyword-spacing' : 2, + 'linebreak-style' : [ 2, 'unix' ], + 'lines-around-comment' : [ 2, + { + allowBlockStart : true, + allowObjectStart : true, + beforeBlockComment : false, + beforeLineComment : false + } + ], + 'max-len' : [ 2, 90, + { + tabWidth : 2, + comments : 90, + ignoreUrls : true, + ignoreStrings : true, + ignoreTemplateLiterals : true, + ignoreRegExpLiterals : true + } + ], + 'newline-after-var' : 2, + 'newline-before-return' : 2, + 'newline-per-chained-call' : 2, + 'no-alert' : 2, + 'no-caller' : 2, + 'no-case-declarations' : 2, + 'no-catch-shadow' : 2, + 'no-class-assign' : 2, + 'no-confusing-arrow' : 2, + 'no-console' : 2, + 'no-const-assign' : 2, + 'no-debugger' : 2, + 'no-dupe-args' : 2, + 'no-dupe-keys' : 2, + 'no-duplicate-case' : 2, + 'no-div-regex' : 2, + 'no-empty' : [ 2, { allowEmptyCatch: true } ], + 'no-empty-pattern' : 2, + 'no-else-return' : 0, + 'no-eval' : 2, + 'no-extend-native' : 2, + 'no-ex-assign' : 2, + 'no-extra-bind' : 2, + 'no-extra-boolean-cast' : 2, + 'no-extra-label' : 2, + 'no-extra-semi' : 2, + 'no-fallthrough' : 2, + 'no-func-assign' : 2, + 'no-global-assign' : 2, + 'no-implicit-coercion' : 2, + 'no-implicit-globals' : 2, + 'no-inner-declarations' : 2, + 'no-invalid-regexp' : 2, + 'no-invalid-this' : 2, + 'no-irregular-whitespace' : 2, + 'no-lonely-if' : 2, + 'no-mixed-operators' : 2, + 'no-mixed-spaces-and-tabs' : 2, + 'no-multi-spaces' : 2, + 'no-multi-str' : 2, + 'no-multiple-empty-lines' : [ 1, { max: 1, maxEOF: 0, maxBOF: 0 } ], + 'no-native-reassign' : 2, + 'no-negated-in-lhs' : 2, + 'no-new' : 2, + 'no-new-func' : 2, + 'no-new-wrappers' : 2, + 'no-obj-calls' : 2, + 'no-proto' : 2, + 'no-prototype-builtins' : 0, + 'no-redeclare' : 2, + 'no-regex-spaces' : 2, + 'no-restricted-imports' : 2, + 'no-return-assign' : 2, + 'no-self-assign' : 2, + 'no-self-compare' : 2, + 'no-sequences' : 2, + 'no-shadow' : 2, + 'no-shadow-restricted-names' : 2, + 'no-spaced-func' : 2, + 'no-sparse-arrays' : 2, + 'no-this-before-super' : 2, + 'no-throw-literal' : 2, + 'no-undef' : 2, + 'no-unexpected-multiline' : 2, + 'no-unmodified-loop-condition' : 2, + 'no-unreachable' : 2, + 'no-unused-vars' : [ 1, { vars: 'all', args: 'after-used' } ], + 'no-use-before-define' : [ 2, { functions: false } ], + 'no-useless-call' : 2, + 'no-useless-computed-key' : 2, + 'no-useless-concat' : 2, + 'no-useless-rename' : 2, + 'no-var' : 2, + 'no-whitespace-before-property' : 2, + 'object-curly-newline' : 0, + 'object-curly-spacing' : [ 2, 'always' ], + 'object-property-newline' : [ 2, { allowMultiplePropertiesPerLine: true } ], + 'prefer-const' : 2, + 'prefer-rest-params' : 2, + 'prefer-spread' : 2, + 'prefer-template' : 2, + 'quotes' : [ 2, 'single', { avoidEscape: true } ], + 'semi' : [ 2, 'always' ], + 'semi-spacing' : 2, + 'space-before-blocks' : 2, + 'space-before-function-paren' : [ 2, + { + anonymous : 'never', + named : 'never', + asyncArrow : 'always' + } + ], + 'space-in-parens' : [ 2, 'never' ], + 'spaced-comment' : [ 2, 'always' ], + 'strict' : 2, + 'valid-typeof' : 2, + 'yoda' : 2 + } +}; + +switch (process.env.MEDIASOUP_NODE_LANGUAGE) +{ + case 'typescript': + { + eslintConfig.parser = '@typescript-eslint/parser'; + eslintConfig.plugins = + [ + ...eslintConfig.plugins, + '@typescript-eslint' + ]; + eslintConfig.extends = + [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended' + ]; + eslintConfig.rules = + { + ...eslintConfig.rules, + 'no-unused-vars' : 0, + '@typescript-eslint/ban-ts-ignore' : 0, + '@typescript-eslint/member-delimiter-style' : [ 2, + { + multiline : { delimiter: 'semi', requireLast: true }, + singleline : { delimiter: 'semi', requireLast: false } + } + ], + '@typescript-eslint/no-explicit-any' : 0, + '@typescript-eslint/no-unused-vars' : [ 2, + { + vars : 'all', + args : 'after-used', + ignoreRestSiblings : false + } + ], + '@typescript-eslint/no-use-before-define' : 0 + }; + + break; + } + + default: + { + throw new TypeError('wrong/missing MEDIASOUP_NODE_LANGUAGE env'); + } +} + +module.exports = eslintConfig; diff --git a/aiortc/.gitignore b/aiortc/.gitignore new file mode 100644 index 0000000..ebcc8e8 --- /dev/null +++ b/aiortc/.gitignore @@ -0,0 +1,12 @@ +/lib +/node_modules/ + +# Vim temporal files. +*.swp +*.swo + +# Mac Stuff. +.DS_Store + +# Vistual Studio Code stuff. +/.vscode/ diff --git a/aiortc/.npmrc b/aiortc/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/aiortc/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/aiortc/README.md b/aiortc/README.md new file mode 100644 index 0000000..6791bcd --- /dev/null +++ b/aiortc/README.md @@ -0,0 +1,6 @@ + +## mediasoup-demo Node app using mediasoup-client-aiortc + +See [index.ts](./src/index.ts). + + diff --git a/aiortc/TODO.md b/aiortc/TODO.md new file mode 100644 index 0000000..2c151d8 --- /dev/null +++ b/aiortc/TODO.md @@ -0,0 +1,2 @@ + +* aiortc RTCPeerConnection does not seem to return from `close()`. diff --git a/aiortc/npm-scripts.js b/aiortc/npm-scripts.js new file mode 100644 index 0000000..1e4df59 --- /dev/null +++ b/aiortc/npm-scripts.js @@ -0,0 +1,67 @@ +const process = require('process'); +const fs = require('fs'); +const { execSync } = require('child_process'); +const { version } = require('./package.json'); + +const task = process.argv.slice(2).join(' '); + +// eslint-disable-next-line no-console +console.log(`npm-scripts.js [INFO] running task "${task}"`); + +switch (task) +{ + case 'typescript:build': + { + execute('rm -rf lib'); + execute('tsc'); + + break; + } + + case 'typescript:watch': + { + const TscWatchClient = require('tsc-watch/client'); + + execute('rm -rf lib'); + + const watch = new TscWatchClient(); + + watch.start('--pretty'); + + break; + } + + case 'lint': + { + execute('MEDIASOUP_NODE_LANGUAGE=typescript eslint -c .eslintrc.js --ext=ts src/'); + + break; + } + + case 'lint:fix': + { + execute('MEDIASOUP_NODE_LANGUAGE=typescript eslint -c .eslintrc.js --fix --ext=ts src/'); + + break; + } + + default: + { + throw new TypeError(`unknown task "${task}"`); + } +} + +function execute(command) +{ + // eslint-disable-next-line no-console + console.log(`npm-scripts.js [INFO] executing command: ${command}`); + + try + { + execSync(command, { stdio: [ 'ignore', process.stdout, process.stderr ] }); + } + catch (error) + { + process.exit(1); + } +} diff --git a/aiortc/package.json b/aiortc/package.json new file mode 100644 index 0000000..cbbfdbf --- /dev/null +++ b/aiortc/package.json @@ -0,0 +1,44 @@ +{ + "name": "mediasoup-demo-aiortc", + "version": "3.0.0", + "private": true, + "description": "mediasoup demo aiortc app", + "contributors": [ + "Iñaki Baz Castillo (https://inakibaz.me)", + "José Luis Millán (https://github.com/jmillan)" + ], + "license": "All Rights Reserved", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "engines": { + "node": ">=8.6.0" + }, + "scripts": { + "typescript:build": "node npm-scripts.js typescript:build", + "typescript:watch": "node npm-scripts.js typescript:watch", + "lint": "node npm-scripts.js lint", + "lint:fix": "node npm-scripts.js lint:fix", + "dev": "cross-env DEBUG=\"${DEBUG:='*'}\" PYTHON_LOG_TO_STDOUT=true ROOM_ID=devel node ./lib/index.js" + }, + "dependencies": { + "debug": "^4.1.1", + "fake-mediastreamtrack": "^1.1.3", + "mediasoup-client-aiortc": "github:versatica/mediasoup-client-aiortc#v3", + "protoo-client": "^4.0.3", + "redux": "^4.0.5", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0", + "repl": "^0.1.3", + "tsc-watch": "^4.1.0", + "typescript": "^4.7.4", + "url-parse": "^1.4.7" + }, + "devDependencies": { + "@types/debug": "^4.1.5", + "@types/node": "^13.7.5", + "@typescript-eslint/eslint-plugin": "^2.21.0", + "@typescript-eslint/parser": "^2.21.0", + "cross-env": "^7.0.1", + "eslint": "^6.8.0" + } +} diff --git a/aiortc/src/Logger.ts b/aiortc/src/Logger.ts new file mode 100644 index 0000000..1ce17d4 --- /dev/null +++ b/aiortc/src/Logger.ts @@ -0,0 +1,47 @@ +import debug from 'debug'; + +const APP_NAME = 'mediasoup-demo-aiortc'; + +export class Logger +{ + private readonly _debug: debug.Debugger; + private readonly _warn: debug.Debugger; + private readonly _error: debug.Debugger; + + constructor(prefix?: string) + { + if (prefix) + { + this._debug = debug(`${APP_NAME}:${prefix}`); + this._warn = debug(`${APP_NAME}:WARN:${prefix}`); + this._error = debug(`${APP_NAME}:ERROR:${prefix}`); + } + else + { + this._debug = debug(APP_NAME); + this._warn = debug(`${APP_NAME}:WARN`); + this._error = debug(`${APP_NAME}:ERROR`); + } + + /* eslint-disable no-console */ + this._debug.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + /* eslint-enable no-console */ + } + + get debug(): debug.Debugger + { + return this._debug; + } + + get warn(): debug.Debugger + { + return this._warn; + } + + get error(): debug.Debugger + { + return this._error; + } +} diff --git a/aiortc/src/RoomClient.ts b/aiortc/src/RoomClient.ts new file mode 100644 index 0000000..153a7cf --- /dev/null +++ b/aiortc/src/RoomClient.ts @@ -0,0 +1,1852 @@ +// @ts-ignore +import protooClient from 'protoo-client'; +import * as mediasoupClient from 'mediasoup-client'; +import { + createWorker, + Worker, + WorkerLogLevel +} from 'mediasoup-client-aiortc'; +import { types as mediasoupClientTypes } from 'mediasoup-client'; +import { Logger } from './Logger'; +import { getProtooUrl } from './urlFactory'; +import * as stateActions from './redux/stateActions'; + +(global as any).createWorker = createWorker; + +const PC_PROPRIETARY_CONSTRAINTS = +{ + optional : [ { googDscp: true } ] +}; + +const logger = new Logger('RoomClient'); + +let store: any; + +export class RoomClient +{ + // Closed flag. + _closed = false; + + // Display name. + _displayName: string; + + // Device info. + _device: any = { + flag : 'aiortc', + name : 'aiortc' + }; + + // Whether we want to force RTC over TCP. + _forceTcp = false; + + // Whether we want to produce audio/video. + _produce = true; + + // Whether we should consume. + _consume = true; + + // Whether we want DataChannels. + _useDataChannel = true; + + // External audio. + _externalAudio = ''; + + // External video. + _externalVideo = ''; + + // Next expected dataChannel test number. + _nextDataChannelTestNumber = 0; + + // Whether simulcast should be used in desktop sharing. + _useSharingSimulcast = false; + + // Protoo URL. + _protooUrl: string; + + // protoo-client Peer instance. + _protoo: any = null; + + // mediasoup-client Device instance. + _mediasoupDevice: mediasoupClientTypes.Device = null; + + // mediasoup Transport for sending. + _sendTransport: mediasoupClientTypes.Transport = null; + + // mediasoup Transport for receiving. + // @type {mediasoupClient.Transport} + _recvTransport: mediasoupClientTypes.Transport = null; + + // Local mic mediasoup Producer. + _micProducer: mediasoupClientTypes.Producer = null; + + // Local webcam mediasoup Producer. + _webcamProducer: mediasoupClientTypes.Producer = null; + + // Local share mediasoup Producer. + _shareProducer: mediasoupClientTypes.Producer = null; + + // Local chat DataProducer. + _chatDataProducer: mediasoupClientTypes.DataProducer = null; + + // Local bot DataProducer. + // @type {mediasoupClient.DataProducer} + _botDataProducer: mediasoupClientTypes.DataProducer = null; + + // mediasoup Consumers. + _consumers: Map = new Map(); + + // mediasoup DataConsumers. + // @type {Map} + _dataConsumers: Map = new Map(); + + // Worker instance. + _worker: Worker; + + // Periodic timer to show local stats. + _localStatsPeriodicTimer: NodeJS.Timer; + + /** + * @param {Object} data + * @param {Object} data.store - The Redux store. + */ + static init(data: any): void + { + store = data.store; + } + + constructor( + { + roomId, + peerId, + displayName, + // useSimulcast, + useSharingSimulcast, + forceTcp, + produce, + consume, + forceH264, + forceVP8, + datachannel, + externalAudio, + externalVideo + }: + { + roomId: string; + peerId: string; + displayName: string; + useSimulcast: boolean; + useSharingSimulcast: boolean; + forceTcp: boolean; + produce: boolean; + consume: boolean; + forceH264: boolean; + forceVP8: boolean; + datachannel: boolean; + externalAudio: string; + externalVideo: string; + } + ) + { + logger.debug( + 'constructor() [roomId:"%s", peerId:"%s", displayName:"%s", device:%s]', + roomId, peerId, displayName, this._device.flag); + + this._displayName = displayName; + this._forceTcp = forceTcp; + this._produce = produce; + this._consume = consume; + this._useDataChannel = datachannel; + this._externalAudio = externalAudio; + this._externalVideo = externalVideo; + + this._useSharingSimulcast = useSharingSimulcast; + this._protooUrl = getProtooUrl({ roomId, peerId }); + this._protoo = null; + } + + close(): void + { + if (this._closed) + return; + + this._closed = true; + + logger.debug('close()'); + + // Close protoo Peer + this._protoo.close(); + + // Close mediasoup Transports. + if (this._sendTransport) + this._sendTransport.close(); + + if (this._recvTransport) + this._recvTransport.close(); + + // Stop the local stats periodic timer. + clearInterval(this._localStatsPeriodicTimer); + + store.dispatch( + stateActions.setRoomState('closed')); + } + + async join(): Promise + { + this._worker = await createWorker( + { + logLevel : process.env.LOGLEVEL as WorkerLogLevel || 'warn' + }); + + const protooTransport = new protooClient.WebSocketTransport(this._protooUrl); + + this._protoo = new protooClient.Peer(protooTransport); + + store.dispatch( + stateActions.setRoomState('connecting')); + + this._protoo.on('open', () => this._joinRoom()); + + this._protoo.on('failed', () => + { + logger.error('WebSocket connection failed'); + }); + + this._protoo.on('disconnected', () => + { + logger.error('WebSocket disconnected'); + + // Close mediasoup Transports. + if (this._sendTransport) + { + this._sendTransport.close(); + this._sendTransport = null; + } + + if (this._recvTransport) + { + this._recvTransport.close(); + this._recvTransport = null; + } + + store.dispatch( + stateActions.setRoomState('closed')); + }); + + this._protoo.on('close', () => + { + if (this._closed) + return; + + this.close(); + }); + + // eslint-disable-next-line no-unused-vars + this._protoo.on('request', async (request: any, accept: any, reject: any) => + { + logger.debug( + 'proto "request" event [method:%s, data:%o]', + request.method, request.data); + + switch (request.method) + { + case 'newConsumer': + { + if (!this._consume) + { + reject(403, 'I do not want to consume'); + + break; + } + + const { + peerId, + producerId, + id, + kind, + rtpParameters, + type, + appData, + producerPaused + } = request.data; + + try + { + const consumer = await this._recvTransport.consume( + { + id, + producerId, + kind, + rtpParameters, + appData : { ...appData, peerId } // Trick. + }); + + // Store in the map. + this._consumers.set(consumer.id, consumer); + + consumer.on('transportclose', () => + { + this._consumers.delete(consumer.id); + }); + + const { spatialLayers, temporalLayers } = + mediasoupClient.parseScalabilityMode( + consumer.rtpParameters.encodings[0].scalabilityMode); + + store.dispatch(stateActions.addConsumer( + { + id : consumer.id, + type : type, + locallyPaused : false, + remotelyPaused : producerPaused, + rtpParameters : consumer.rtpParameters, + spatialLayers : spatialLayers, + temporalLayers : temporalLayers, + preferredSpatialLayer : spatialLayers - 1, + preferredTemporalLayer : temporalLayers - 1, + priority : 1, + codec : consumer.rtpParameters.codecs[0].mimeType.split('/')[1], + track : consumer.track + }, + peerId)); + + // We are ready. Answer the protoo request so the server will + // resume this Consumer (which was paused for now if video). + accept(); + + // If audio-only mode is enabled, pause it. + if (consumer.kind === 'video' && store.getState().me.audioOnly) + this._pauseConsumer(consumer); + } + catch (error) + { + logger.error('"newConsumer" request failed:%o', error); + + throw error; + } + + break; + } + + case 'newDataConsumer': + { + if (!this._consume) + { + reject(403, 'I do not want to data consume'); + + break; + } + + if (!this._useDataChannel) + { + reject(403, 'I do not want DataChannels'); + + break; + } + + const { + peerId, // NOTE: Null if bot. + dataProducerId, + id, + sctpStreamParameters, + label, + protocol, + appData + } = request.data; + + try + { + const dataConsumer = await this._recvTransport.consumeData( + { + id, + dataProducerId, + sctpStreamParameters, + label, + protocol, + appData : { ...appData, peerId } // Trick. + }); + + // Store in the map. + this._dataConsumers.set(dataConsumer.id, dataConsumer); + + dataConsumer.on('transportclose', () => + { + this._dataConsumers.delete(dataConsumer.id); + }); + + dataConsumer.on('open', () => + { + logger.debug('DataConsumer "open" event'); + }); + + dataConsumer.on('close', () => + { + logger.warn('DataConsumer "close" event'); + + this._dataConsumers.delete(dataConsumer.id); + }); + + dataConsumer.on('error', (error) => + { + logger.error('DataConsumer "error" event:%o', error); + }); + + dataConsumer.on('message', (message) => + { + logger.debug( + 'DataConsumer "message" event [streamId:%d]', + dataConsumer.sctpStreamParameters.streamId); + + if (message instanceof ArrayBuffer) + { + const view = new DataView(message); + const number = view.getUint32(0); + + if (number == Math.pow(2, 32) - 1) + { + logger.warn('dataChannelTest finished!'); + + this._nextDataChannelTestNumber = 0; + + return; + } + + if (number > this._nextDataChannelTestNumber) + { + logger.warn( + 'dataChannelTest: %s packets missing', + number - this._nextDataChannelTestNumber); + } + + this._nextDataChannelTestNumber = number + 1; + + return; + } + else if (typeof message !== 'string') + { + logger.warn('ignoring DataConsumer "message" (not a string)'); + + return; + } + + switch (dataConsumer.label) + { + case 'chat': + { + const { peers } = store.getState(); + const peersArray = Object.keys(peers) + .map((_peerId) => peers[_peerId]); + const sendingPeer = peersArray + .find((peer) => peer.dataConsumers.includes(dataConsumer.id)); + + if (!sendingPeer) + { + logger.warn('DataConsumer "message" from unknown peer'); + + break; + } + + logger.debug(`${sendingPeer.displayName} says: "${message}"`); + + break; + } + + case 'bot': + { + logger.debug(`message from Bot: "${message}"`); + + break; + } + } + }); + + // For the interactive terminal. + (global as any).DC = dataConsumer; + + store.dispatch(stateActions.addDataConsumer( + { + id : dataConsumer.id, + sctpStreamParameters : dataConsumer.sctpStreamParameters, + label : dataConsumer.label, + protocol : dataConsumer.protocol + }, + peerId)); + + // We are ready. Answer the protoo request. + accept(); + } + catch (error) + { + logger.error('"newDataConsumer" request failed:%o', error); + + throw error; + } + + break; + } + } + }); + + this._protoo.on('notification', (notification: any) => + { + logger.debug( + 'proto "notification" event [method:%s, data:%o]', + notification.method, notification.data); + + switch (notification.method) + { + case 'producerScore': + { + const { producerId, score } = notification.data; + + store.dispatch( + stateActions.setProducerScore(producerId, score)); + + break; + } + + case 'newPeer': + { + const peer = notification.data; + + store.dispatch( + stateActions.addPeer( + { ...peer, consumers: [], dataConsumers: [] })); + + logger.debug(`${peer.displayName} has joined the room`); + + break; + } + + case 'peerClosed': + { + const { peerId } = notification.data; + + store.dispatch( + stateActions.removePeer(peerId)); + + break; + } + + case 'peerDisplayNameChanged': + { + const { peerId, displayName, oldDisplayName } = notification.data; + + store.dispatch( + stateActions.setPeerDisplayName(displayName, peerId)); + + logger.debug(`${oldDisplayName} is now ${displayName}`); + + break; + } + + case 'consumerClosed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + consumer.close(); + this._consumers.delete(consumerId); + + const { peerId } = consumer.appData; + + store.dispatch( + stateActions.removeConsumer(consumerId, peerId as string)); + + break; + } + + case 'consumerPaused': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch( + stateActions.setConsumerPaused(consumerId, 'remote')); + + break; + } + + case 'consumerResumed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch( + stateActions.setConsumerResumed(consumerId, 'remote')); + + break; + } + + case 'consumerLayersChanged': + { + const { consumerId, spatialLayer, temporalLayer } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch(stateActions.setConsumerCurrentLayers( + consumerId, spatialLayer, temporalLayer)); + + break; + } + + case 'consumerScore': + { + const { consumerId, score } = notification.data; + + store.dispatch( + stateActions.setConsumerScore(consumerId, score)); + + break; + } + + case 'dataConsumerClosed': + { + const { dataConsumerId } = notification.data; + const dataConsumer = this._dataConsumers.get(dataConsumerId); + + if (!dataConsumer) + break; + + dataConsumer.close(); + this._dataConsumers.delete(dataConsumerId); + + const { peerId } = dataConsumer.appData; + + store.dispatch( + stateActions.removeDataConsumer(dataConsumerId, peerId as string)); + + break; + } + + case 'activeSpeaker': + { + const { peerId } = notification.data; + + store.dispatch( + stateActions.setRoomActiveSpeaker(peerId)); + + break; + } + + default: + { + logger.error( + 'unknown protoo notification.method "%s"', notification.method); + } + } + }); + } + + async enableMic(): Promise + { + logger.debug('enableMic()'); + + if (this._micProducer) + return; + + if (!this._mediasoupDevice.canProduce('audio')) + { + logger.error('enableMic() | cannot produce audio'); + + return; + } + + let stream; + let track; + + try + { + if (!this._externalAudio) + { + stream = await this._worker.getUserMedia( + { + audio : { source: 'device' } + }); + } + else + { + stream = await this._worker.getUserMedia( + { + audio : + { + source : this._externalAudio.startsWith('http') ? 'url' : 'file', + file : this._externalAudio, + url : this._externalAudio + } + }); + } + + // TODO: For testing. + (global as any).audioStream = stream; + + track = stream.getAudioTracks()[0]; + + this._micProducer = await this._sendTransport.produce( + { + track, + codecOptions : + { + opusStereo : true, + opusDtx : true + } + }); + + store.dispatch(stateActions.addProducer( + { + id : this._micProducer.id, + paused : this._micProducer.paused, + track : this._micProducer.track, + rtpParameters : this._micProducer.rtpParameters, + codec : this._micProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._micProducer.on('transportclose', () => + { + this._micProducer = null; + }); + + this._micProducer.on('trackended', () => + { + logger.error('Microphone disconnected!'); + + this.disableMic() + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + }); + } + catch (error) + { + logger.error('enableMic() | failed:%o', error); + + if (track) + track.stop(); + } + } + + async disableMic(): Promise + { + logger.debug('disableMic()'); + + if (!this._micProducer) + return; + + this._micProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._micProducer.id)); + + try + { + await this._protoo.request( + 'closeProducer', { producerId: this._micProducer.id }); + } + catch (error) + { + logger.error(`Error closing server-side mic Producer: ${error}`); + } + + this._micProducer = null; + } + + async muteMic(): Promise + { + logger.debug('muteMic()'); + + this._micProducer.pause(); + + try + { + await this._protoo.request( + 'pauseProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerPaused(this._micProducer.id)); + } + catch (error) + { + logger.error('muteMic() | failed: %o', error); + } + } + + async unmuteMic(): Promise + { + logger.debug('unmuteMic()'); + + this._micProducer.resume(); + + try + { + await this._protoo.request( + 'resumeProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerResumed(this._micProducer.id)); + } + catch (error) + { + logger.error('unmuteMic() | failed: %o', error); + } + } + + async enableWebcam(): Promise + { + logger.debug('enableWebcam()'); + + if (this._webcamProducer) + return; + + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableWebcam() | cannot produce video'); + + return; + } + + store.dispatch( + stateActions.setWebcamInProgress(true)); + + let stream; + let track; + + try + { + if (!this._externalVideo) + { + stream = await this._worker.getUserMedia( + { + video : { source: 'device' } + }); + } + else + { + stream = await this._worker.getUserMedia( + { + video : + { + source : this._externalVideo.startsWith('http') ? 'url' : 'file', + file : this._externalVideo, + url : this._externalVideo + } + }); + } + + // TODO: For testing. + (global as any).videoStream = stream; + + track = stream.getVideoTracks()[0]; + + this._webcamProducer = await this._sendTransport.produce({ track }); + + // TODO. + const device = { + label : 'rear-xyz' + }; + + store.dispatch(stateActions.addProducer( + { + id : this._webcamProducer.id, + deviceLabel : device.label, + type : this._getWebcamType(device), + paused : this._webcamProducer.paused, + track : this._webcamProducer.track, + rtpParameters : this._webcamProducer.rtpParameters, + codec : this._webcamProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._webcamProducer.on('transportclose', () => + { + this._webcamProducer = null; + }); + + this._webcamProducer.on('trackended', () => + { + logger.error('Webcam disconnected!'); + + this.disableWebcam() + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + }); + } + catch (error) + { + logger.error('enableWebcam() | failed:%o', error); + + logger.error('enabling Webcam!'); + + if (track) + track.stop(); + } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + } + + async disableWebcam(): Promise + { + logger.debug('disableWebcam()'); + + if (!this._webcamProducer) + return; + + this._webcamProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._webcamProducer.id)); + + try + { + await this._protoo.request( + 'closeProducer', { producerId: this._webcamProducer.id }); + } + catch (error) + { + logger.error(`Error closing server-side webcam Producer: ${error}`); + } + + this._webcamProducer = null; + } + + async muteWebcam(): Promise + { + logger.debug('muteWebcam()'); + + try + { + this._webcamProducer.pause(); + + await this._protoo.request( + 'pauseProducer', { producerId: this._webcamProducer.id }); + + store.dispatch( + stateActions.setProducerPaused(this._webcamProducer.id)); + } + catch (error) + { + logger.error('muteWebcam() | failed: %o', error); + } + } + + async unmuteWebcam(): Promise + { + logger.debug('unmuteWebcam()'); + + try + { + this._webcamProducer.resume(); + + await this._protoo.request( + 'resumeProducer', { producerId: this._webcamProducer.id }); + + store.dispatch( + stateActions.setProducerResumed(this._webcamProducer.id)); + } + catch (error) + { + logger.error('unmuteWebcam() | failed: %o', error); + } + } + + async changeWebcam(): Promise + { + logger.debug('changeWebcam()'); + + if (!this._webcamProducer) + throw new Error('webcam not enabled'); + + store.dispatch( + stateActions.setWebcamInProgress(true)); + + let stream; + let track; + + try + { + if (!this._externalVideo) + { + stream = await this._worker.getUserMedia( + { + video : { source: 'device' } + }); + } + else + { + stream = await this._worker.getUserMedia( + { + video : + { + source : this._externalVideo.startsWith('http') ? 'url' : 'file', + file : this._externalVideo, + url : this._externalVideo + } + }); + } + + // TODO: For testing. + (global as any).videoStream = stream; + + track = stream.getVideoTracks()[0]; + + await this._webcamProducer.replaceTrack({ track }); + + store.dispatch(stateActions.setProducerTrack( + this._webcamProducer.id, track)); + + this._webcamProducer.on('transportclose', () => + { + this._webcamProducer = null; + }); + + this._webcamProducer.on('trackended', () => + { + logger.error('Webcam disconnected!'); + + this.disableWebcam() + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + }); + } + catch (error) + { + logger.error('changeWebcam() | failed:%o', error); + + logger.error('enabling Webcam!'); + + if (track) + track.stop(); + } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + + } + + async enableAudioOnly(): Promise + { + logger.debug('enableAudioOnly()'); + + store.dispatch( + stateActions.setAudioOnlyInProgress(true)); + + this.disableWebcam(); + + for (const consumer of this._consumers.values()) + { + if (consumer.kind !== 'video') + continue; + + this._pauseConsumer(consumer); + } + + store.dispatch( + stateActions.setAudioOnlyState(true)); + + store.dispatch( + stateActions.setAudioOnlyInProgress(false)); + } + + async disableAudioOnly(): Promise + { + logger.debug('disableAudioOnly()'); + + store.dispatch( + stateActions.setAudioOnlyInProgress(true)); + + if ( + !this._webcamProducer && + this._produce + ) + { + this.enableWebcam(); + } + + for (const consumer of this._consumers.values()) + { + if (consumer.kind !== 'video') + continue; + + this._resumeConsumer(consumer); + } + + store.dispatch( + stateActions.setAudioOnlyState(false)); + + store.dispatch( + stateActions.setAudioOnlyInProgress(false)); + } + + async muteAudio(): Promise + { + logger.debug('muteAudio()'); + + store.dispatch( + stateActions.setAudioMutedState(true)); + } + + async unmuteAudio(): Promise + { + logger.debug('unmuteAudio()'); + + store.dispatch( + stateActions.setAudioMutedState(false)); + } + + async restartIce(): Promise + { + logger.debug('restartIce()'); + + store.dispatch( + stateActions.setRestartIceInProgress(true)); + + try + { + if (this._sendTransport) + { + const iceParameters = await this._protoo.request( + 'restartIce', + { transportId: this._sendTransport.id }); + + await this._sendTransport.restartIce({ iceParameters }); + } + + if (this._recvTransport) + { + const iceParameters = await this._protoo.request( + 'restartIce', + { transportId: this._recvTransport.id }); + + await this._recvTransport.restartIce({ iceParameters }); + } + + logger.debug('ICE restarted'); + } + catch (error) + { + logger.error('restartIce() | failed:%o', error); + } + + store.dispatch( + stateActions.setRestartIceInProgress(false)); + } + + async setConsumerPriority(consumerId: string, priority: number): Promise + { + logger.debug( + 'setConsumerPriority() [consumerId:%s, priority:%d]', + consumerId, priority); + + try + { + await this._protoo.request('setConsumerPriority', { consumerId, priority }); + + store.dispatch(stateActions.setConsumerPriority(consumerId, priority)); + } + catch (error) + { + logger.error('setConsumerPriority() | failed:%o', error); + } + } + + async requestConsumerKeyFrame(consumerId: string): Promise + { + logger.debug('requestConsumerKeyFrame() [consumerId:%s]', consumerId); + + try + { + await this._protoo.request('requestConsumerKeyFrame', { consumerId }); + + logger.debug('Keyframe requested for video consumer'); + } + catch (error) + { + logger.error('requestConsumerKeyFrame() | failed:%o', error); + } + } + + async enableChatDataProducer(): Promise + { + logger.debug('enableChatDataProducer()'); + + if (!this._useDataChannel) + return; + + // NOTE: Should enable this code but it's useful for testing. + // if (this._chatDataProducer) + // return; + + try + { + // Create chat DataProducer. + this._chatDataProducer = await this._sendTransport.produceData( + { + ordered : false, + maxRetransmits : 1, + label : 'chat', + appData : { info: 'my-chat-DataProducer' } + }); + + store.dispatch(stateActions.addDataProducer( + { + id : this._chatDataProducer.id, + sctpStreamParameters : this._chatDataProducer.sctpStreamParameters, + label : this._chatDataProducer.label, + protocol : this._chatDataProducer.protocol + })); + + this._chatDataProducer.on('transportclose', () => + { + this._chatDataProducer = null; + }); + + this._chatDataProducer.on('open', () => + { + logger.debug('chat DataProducer "open" event'); + }); + + this._chatDataProducer.on('close', () => + { + logger.error('chat DataProducer "close" event'); + + this._chatDataProducer = null; + }); + + this._chatDataProducer.on('error', (error) => + { + logger.error('chat DataProducer "error" event:%o', error); + }); + + this._chatDataProducer.on('bufferedamountlow', () => + { + logger.debug('chat DataProducer "bufferedamountlow" event'); + }); + } + catch (error) + { + logger.error('enableChatDataProducer() | failed:%o', error); + + throw error; + } + } + + async enableBotDataProducer(): Promise + { + logger.debug('enableBotDataProducer()'); + + if (!this._useDataChannel) + return; + + // NOTE: Should enable this code but it's useful for testing. + // if (this._botDataProducer) + // return; + + try + { + // Create chat DataProducer. + this._botDataProducer = await this._sendTransport.produceData( + { + ordered : false, + maxPacketLifeTime : 2000, + label : 'bot', + appData : { info: 'my-bot-DataProducer' } + }); + + store.dispatch(stateActions.addDataProducer( + { + id : this._botDataProducer.id, + sctpStreamParameters : this._botDataProducer.sctpStreamParameters, + label : this._botDataProducer.label, + protocol : this._botDataProducer.protocol + })); + + this._botDataProducer.on('transportclose', () => + { + this._botDataProducer = null; + }); + + this._botDataProducer.on('open', () => + { + logger.debug('bot DataProducer "open" event'); + }); + + this._botDataProducer.on('close', () => + { + logger.error('bot DataProducer "close" event'); + + this._botDataProducer = null; + }); + + this._botDataProducer.on('error', (error) => + { + logger.error('bot DataProducer "error" event:%o', error); + }); + + this._botDataProducer.on('bufferedamountlow', () => + { + logger.debug('bot DataProducer "bufferedamountlow" event'); + }); + } + catch (error) + { + logger.error('enableBotDataProducer() | failed:%o', error); + + throw error; + } + } + + async sendChatMessage(text: string): Promise + { + logger.debug('sendChatMessage() [text:"%s]', text); + + if (!this._chatDataProducer) + { + logger.error('No chat DataProducer'); + + return; + } + + try + { + this._chatDataProducer.send(text); + } + catch (error) + { + logger.error('chat DataProducer.send() failed:%o', error); + } + } + + async sendBotMessage(text: string): Promise + { + logger.debug('sendBotMessage() [text:"%s]', text); + + if (!this._botDataProducer) + { + logger.error('No bot DataProducer'); + + return; + } + + try + { + this._botDataProducer.send(text); + } + catch (error) + { + logger.error('bot DataProducer.send() failed:%o', error); + } + } + + async changeDisplayName(displayName: string): Promise + { + logger.debug('changeDisplayName() [displayName:"%s"]', displayName); + + const previousDisplayName = this._displayName; + + try + { + await this._protoo.request('changeDisplayName', { displayName }); + + this._displayName = displayName; + + logger.debug('Display name changed'); + + store.dispatch( + stateActions.setDisplayName(displayName)); + } + catch (error) + { + logger.error('changeDisplayName() | failed: %o', error); + + // We need to refresh the component for it to render the previous + // displayName again. + store.dispatch( + stateActions.setDisplayName(previousDisplayName)); + } + } + + async getSendTransportRemoteStats(): Promise + { + logger.debug('getSendTransportRemoteStats()'); + + if (!this._sendTransport) + return; + + return this._protoo.request( + 'getTransportStats', { transportId: this._sendTransport.id }); + } + + async getRecvTransportRemoteStats(): Promise + { + logger.debug('getRecvTransportRemoteStats()'); + + if (!this._recvTransport) + return; + + return this._protoo.request( + 'getTransportStats', { transportId: this._recvTransport.id }); + } + + async getAudioRemoteStats(): Promise + { + logger.debug('getAudioRemoteStats()'); + + if (!this._micProducer) + return; + + return this._protoo.request( + 'getProducerStats', { producerId: this._micProducer.id }); + } + + async getVideoRemoteStats(): Promise + { + logger.debug('getVideoRemoteStats()'); + + const producer = this._webcamProducer || this._shareProducer; + + if (!producer) + return; + + return this._protoo.request( + 'getProducerStats', { producerId: producer.id }); + } + + async getConsumerRemoteStats(consumerId: string): Promise + { + logger.debug('getConsumerRemoteStats()'); + + const consumer = this._consumers.get(consumerId); + + if (!consumer) + return; + + return this._protoo.request('getConsumerStats', { consumerId }); + } + + async getChatDataProducerRemoteStats(): Promise + { + logger.debug('getChatDataProducerRemoteStats()'); + + const dataProducer = this._chatDataProducer; + + if (!dataProducer) + return; + + return this._protoo.request( + 'getDataProducerStats', { dataProducerId: dataProducer.id }); + } + + async getBotDataProducerRemoteStats(): Promise + { + logger.debug('getBotDataProducerRemoteStats()'); + + const dataProducer = this._botDataProducer; + + if (!dataProducer) + return; + + return this._protoo.request( + 'getDataProducerStats', { dataProducerId: dataProducer.id }); + } + + async getDataConsumerRemoteStats(dataConsumerId: string): Promise + { + logger.debug('getDataConsumerRemoteStats()'); + + const dataConsumer = this._dataConsumers.get(dataConsumerId); + + if (!dataConsumer) + return; + + return this._protoo.request('getDataConsumerStats', { dataConsumerId }); + } + + async getSendTransportLocalStats(): Promise + { + logger.debug('getSendTransportLocalStats()'); + + if (!this._sendTransport) + return undefined; + + return this._sendTransport.getStats(); + } + + async getRecvTransportLocalStats(): Promise + { + logger.debug('getRecvTransportLocalStats()'); + + if (!this._recvTransport) + return undefined; + + return this._recvTransport.getStats(); + } + + async getAudioLocalStats(): Promise + { + logger.debug('getAudioLocalStats()'); + + if (!this._micProducer) + return; + + return this._micProducer.getStats(); + } + + async getVideoLocalStats(): Promise + { + logger.debug('getVideoLocalStats()'); + + const producer = this._webcamProducer || this._shareProducer; + + if (!producer) + return; + + return producer.getStats(); + } + + async getConsumerLocalStats(consumerId: string): Promise + { + const consumer = this._consumers.get(consumerId); + + if (!consumer) + return; + + return consumer.getStats(); + } + + async showLocalStats(): Promise + { + logger.debug('showLocalStats()'); + + const sendTransportStats = await this.getSendTransportLocalStats(); + const recvTransportStats = await this.getRecvTransportLocalStats(); + const audioStats = await this.getAudioLocalStats(); + const videoStats = await this.getVideoLocalStats(); + + const stats = + { + sendTransport : sendTransportStats + ? Array.from(sendTransportStats.values()) + : undefined, + recvTransport : recvTransportStats + ? Array.from(recvTransportStats.values()) + : undefined, + audio : audioStats + ? Array.from(audioStats.values()) + : undefined, + video : videoStats + ? Array.from(videoStats.values()) + : undefined + }; + + clearInterval(this._localStatsPeriodicTimer); + + this._localStatsPeriodicTimer = setInterval(() => + { + logger.debug('local stats:'); + logger.debug(JSON.stringify(stats, null, ' ')); + }, 2500); + } + + async hideLocalStats(): Promise + { + logger.debug('hideLocalStats()'); + + clearInterval(this._localStatsPeriodicTimer); + } + + async _joinRoom(): Promise + { + logger.debug('_joinRoom()'); + + try + { + this._mediasoupDevice = new mediasoupClient.Device( + { + handlerFactory : this._worker.createHandlerFactory() + }); + + const routerRtpCapabilities = + await this._protoo.request('getRouterRtpCapabilities'); + + await this._mediasoupDevice.load({ routerRtpCapabilities }); + + // Create mediasoup Transport for sending (unless we don't want to produce). + if (this._produce) + { + const transportInfo = await this._protoo.request( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : true, + consuming : false, + sctpCapabilities : this._useDataChannel + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + const { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters + } = transportInfo; + + this._sendTransport = this._mediasoupDevice.createSendTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers : [], + proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS + }); + + this._sendTransport.on( + 'connect', ({ iceParameters, dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this._protoo.request( + 'connectWebRtcTransport', + { + transportId : this._sendTransport.id, + iceParameters, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + + this._sendTransport.on( + 'produce', async ({ kind, rtpParameters, appData }, callback, errback) => + { + try + { + // eslint-disable-next-line no-shadow + const { id } = await this._protoo.request( + 'produce', + { + transportId : this._sendTransport.id, + kind, + rtpParameters, + appData + }); + + callback({ id }); + } + catch (error) + { + errback(error as Error); + } + }); + + this._sendTransport.on('producedata', async ( + { + sctpStreamParameters, + label, + protocol, + appData + }, + callback, + errback + ) => + { + logger.debug( + '"producedata" event: [sctpStreamParameters:%o, appData:%o]', + sctpStreamParameters, appData); + + try + { + // eslint-disable-next-line no-shadow + const { id } = await this._protoo.request( + 'produceData', + { + transportId : this._sendTransport.id, + sctpStreamParameters, + label, + protocol, + appData + }); + + callback({ id }); + } + catch (error) + { + errback(error as Error); + } + }); + } + + // Create mediasoup Transport for sending (unless we don't want to consume). + if (this._consume) + { + const transportInfo = await this._protoo.request( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : false, + consuming : true, + sctpCapabilities : this._useDataChannel + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + const { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters + } = transportInfo; + + this._recvTransport = this._mediasoupDevice.createRecvTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers : [] + }); + + this._recvTransport.on( + 'connect', ({ iceParameters, dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this._protoo.request( + 'connectWebRtcTransport', + { + transportId : this._recvTransport.id, + iceParameters, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + } + + // Join now into the room. + // NOTE: Don't send our RTP capabilities if we don't want to consume. + const { peers } = await this._protoo.request( + 'join', + { + displayName : this._displayName, + device : this._device, + rtpCapabilities : this._consume + ? this._mediasoupDevice.rtpCapabilities + : undefined, + sctpCapabilities : this._useDataChannel && this._consume + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + store.dispatch( + stateActions.setRoomState('connected')); + + // Clean all the existing notifcations. + store.dispatch( + stateActions.removeAllNotifications()); + + logger.debug('You are in the room!'); + + for (const peer of peers) + { + store.dispatch( + stateActions.addPeer( + { ...peer, consumers: [], dataConsumers: [] })); + } + + // Enable mic/webcam. + if (this._produce) + { + // Set our media capabilities. + store.dispatch(stateActions.setMediaCapabilities( + { + canSendMic : this._mediasoupDevice.canProduce('audio'), + canSendWebcam : this._mediasoupDevice.canProduce('video') + })); + + this.enableMic(); + this.enableWebcam(); + + this._sendTransport.on('connectionstatechange', (connectionState) => + { + if (connectionState === 'connected') + { + this.enableChatDataProducer(); + this.enableBotDataProducer(); + } + }); + } + } + catch (error) + { + logger.error('_joinRoom() failed:%o', error); + + this.close(); + } + } + + _getWebcamType(device: any): string + { + if (/(back|rear)/i.test(device.label)) + { + logger.debug('_getWebcamType() | it seems to be a back camera'); + + return 'back'; + } + else + { + logger.debug('_getWebcamType() | it seems to be a front camera'); + + return 'front'; + } + } + + async _pauseConsumer(consumer: mediasoupClientTypes.Consumer): Promise + { + if (consumer.paused) + return; + + try + { + await this._protoo.request('pauseConsumer', { consumerId: consumer.id }); + + consumer.pause(); + + store.dispatch( + stateActions.setConsumerPaused(consumer.id, 'local')); + } + catch (error) + { + logger.error('_pauseConsumer() | failed:%o', error); + } + } + + async _resumeConsumer(consumer: mediasoupClientTypes.Consumer): Promise + { + if (!consumer.paused) + return; + + try + { + await this._protoo.request('resumeConsumer', { consumerId: consumer.id }); + + consumer.resume(); + + store.dispatch( + stateActions.setConsumerResumed(consumer.id, 'local')); + } + catch (error) + { + logger.error('_resumeConsumer() | failed:%o', error); + } + } +} diff --git a/aiortc/src/index.ts b/aiortc/src/index.ts new file mode 100644 index 0000000..e40b5f4 --- /dev/null +++ b/aiortc/src/index.ts @@ -0,0 +1,89 @@ +import * as repl from 'repl'; +import { + applyMiddleware as applyReduxMiddleware, + createStore as createReduxStore +} from 'redux'; +import thunk from 'redux-thunk'; +import { Logger } from './Logger'; +import { RoomClient } from './RoomClient'; +import reducers from './redux/reducers'; + +const reduxMiddlewares = [ thunk ]; + +const logger = new Logger(); + +const store = createReduxStore( + reducers, + undefined, + applyReduxMiddleware(...reduxMiddlewares) +); + +RoomClient.init({ store }); + +const roomId = process.env.ROOM_ID || 'test'; +const peerId = process.env.PEER_ID || 'mediasoup-client-aiortc-id'; +const displayName = process.env.DISPLAY_NAME || 'mediasoup-client-aiortc'; +const forceTcp = process.env.FORCE_TCP === 'true' ? true : false; +const produce = process.env.PRODUCE === 'false' ? false : true; +const consume = process.env.CONSUME === 'false' ? false : true; +const forceH264 = process.env.FORCE_H264 === 'true' ? true : false; +const forceVP8 = process.env.FORCE_VP8 === 'true' ? true : false; +const datachannel = process.env.DATACHANNEL === 'false' ? false : true; +const externalAudio = process.env.EXTERNAL_AUDIO || ''; +const externalVideo = process.env.EXTERNAL_VIDEO || ''; + +const options = +{ + roomId, + peerId, + displayName, + useSimulcast : false, + useSharingSimulcast : false, + forceTcp, + produce, + consume, + forceH264, + forceVP8, + datachannel, + externalAudio, + externalVideo +}; + +logger.debug(`starting mediasoup-demo-aiortc: ${JSON.stringify(options, undefined, 2)}`); + +const roomClient = new RoomClient(options); + +// For the interactive terminal. +(global as any).store = store; +(global as any).roomClient = roomClient; + +// Run an interactive terminal. +const terminal = repl.start({ + terminal : true, + prompt : 'terminal> ', + useColors : true, + useGlobal : true, + ignoreUndefined : false +}); + +terminal.on('exit', () => process.exit(0)); + +// Join!! +roomClient.join(); + +// NOTE: Debugging stuff. + +(global as any).__sendSdps = function(): void +{ + // eslint-disable-next-line no-console + console.warn('>>> send transport local SDP offer:'); + // @ts-ignore + roomClient._sendTransport._handler._channel.request( + 'handler.getLocalDescription', + // @ts-ignore + roomClient._sendTransport._handler._internal) + .then((desc: any) => + { + logger.warn(desc.sdp); + }); +}; diff --git a/aiortc/src/redux/STATE.md b/aiortc/src/redux/STATE.md new file mode 100644 index 0000000..bd2c52e --- /dev/null +++ b/aiortc/src/redux/STATE.md @@ -0,0 +1,148 @@ +# APP STATE + +```js +{ + room : + { + url : 'https://demo.mediasoup.org/?roomId=d0el8y34', + state : 'connected', // new/connecting/connected/closed + activeSpeakerId : 'alice', + statsPeerId : null, + faceDetection : false + }, + me : + { + id : 'bob', + displayName : 'Bob McFLower', + displayNameSet : false, // true if got from cookie or manually set. + device : { flag: 'firefox', name: 'Firefox', version: '61' }, + canSendMic : true, + canSendWebcam : true, + canChangeWebcam : false, + webcamInProgress : false, + shareInProgress : false, + audioOnly : false, + audioOnlyInProgress : false, + audioMuted : false, + restartIceInProgress : false + }, + producers : + { + '1111-qwer' : + { + id : '1111-qwer', + paused : true, + track : MediaStreamTrack, + codec : 'opus', + rtpParameters : {}, + score : [ { ssrc: 1111, score: 10 } ] + }, + '1112-asdf' : + { + id : '1112-asdf', + deviceLabel : 'Macbook Webcam', + type : 'front', // front/back/share + paused : false, + track : MediaStreamTrack, + codec : 'vp8', + score : [ { ssrc: 2221, score: 10 }, { ssrc: 2222, score: 9 } ] + } + }, + dataProducers : + { + '4444-4444' : + { + id : '4444-4444', + sctpStreamParameters : {}, + label : 'chat', + protocol : '' + }, + '4444-4445' : + { + id : '4444-4445', + sctpStreamParameters : {}, + label : 'bot', + protocol : '' + } + }, + peers : + { + 'alice' : + { + id : 'alice', + displayName : 'Alice Thomsom', + device : { flag: 'chrome', name: 'Chrome', version: '58' }, + consumers : [ '5551-qwer', '5552-zxzx' ], + dataConsumers : [ '6661-asdf' ] + } + }, + consumers : + { + '5551-qwer' : + { + id : '5551-qwer', + type : 'simple', + locallyPaused : false, + remotelyPaused : false, + rtpParameters : {}, + codec : 'opus', + spatialLayers : 1, + temporalLayers : 1, + currentSpatialLayer : undefined, + currentTemporalLayer : undefined, + preferredSpatialLayer : undefined, + preferredTemporalLayer : undefined, + priority : 1, + track : MediaStreamTrack, + score : [ { ssrc: 3331, score: 10 } ] + }, + '5552-zxzx' : + { + id : '5552-zxzx', + type : 'simulcast', + locallyPaused : false, + remotelyPaused : true, + rtpParameters : {}, + codec : 'h264', + spatialLayers : 1, + temporalLayers : 1, + currentSpatialLayer : 1, + currentTemporalLayer : 1, + preferredSpatialLayer : 2, + preferredTemporalLayer : 2, + priority : 2, + track : MediaStreamTrack, + score : [ { ssrc: 4441, score: 9 }, { ssrc: 4444, score: 8 } ] + } + }, + dataConsumers : + { + '5551-qwer' : + { + id : '5551-qwer', + sctpStreamParameters : {}, + label : 'chat', + protocol : 'something' + }, + '5552-qwer' : + { + id : '5552-qwer', + sctpStreamParameters : {}, + label : 'bot' + } + }, + notifications : + [ + { + id : 'qweasdw43we', + type : 'info' // info/error + text : 'You joined the room' + }, + { + id : 'j7sdhkjjkcc', + type : 'error' + text : 'Could not add webcam' + } + ] +} +``` diff --git a/aiortc/src/redux/reducers/consumers.ts b/aiortc/src/redux/reducers/consumers.ts new file mode 100644 index 0000000..77f85df --- /dev/null +++ b/aiortc/src/redux/reducers/consumers.ts @@ -0,0 +1,136 @@ +const initialState = {}; + +const consumers = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + return {}; + else + return state; + } + + case 'ADD_CONSUMER': + { + const { consumer } = action.payload; + + return { ...state, [consumer.id]: consumer }; + } + + case 'REMOVE_CONSUMER': + { + const { consumerId } = action.payload; + const newState = { ...state }; + + // @ts-ignore + delete newState[consumerId]; + + return newState; + } + + case 'SET_CONSUMER_PAUSED': + { + const { consumerId, originator } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + let newConsumer; + + if (originator === 'local') + newConsumer = { ...consumer, locallyPaused: true }; + else + newConsumer = { ...consumer, remotelyPaused: true }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_RESUMED': + { + const { consumerId, originator } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + let newConsumer; + + if (originator === 'local') + newConsumer = { ...consumer, locallyPaused: false }; + else + newConsumer = { ...consumer, remotelyPaused: false }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_CURRENT_LAYERS': + { + const { consumerId, spatialLayer, temporalLayer } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + const newConsumer = + { + ...consumer, + currentSpatialLayer : spatialLayer, + currentTemporalLayer : temporalLayer + }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_PREFERRED_LAYERS': + { + const { consumerId, spatialLayer, temporalLayer } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + const newConsumer = + { + ...consumer, + preferredSpatialLayer : spatialLayer, + preferredTemporalLayer : temporalLayer + }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_PRIORITY': + { + const { consumerId, priority } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + const newConsumer = { ...consumer, priority }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_TRACK': + { + const { consumerId, track } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + const newConsumer = { ...consumer, track }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_SCORE': + { + const { consumerId, score } = action.payload; + // @ts-ignore + const consumer = state[consumerId]; + + if (!consumer) + return state; + + const newConsumer = { ...consumer, score }; + + return { ...state, [consumerId]: newConsumer }; + } + + default: + { + return state; + } + } +}; + +export default consumers; diff --git a/aiortc/src/redux/reducers/dataConsumers.ts b/aiortc/src/redux/reducers/dataConsumers.ts new file mode 100644 index 0000000..06569d3 --- /dev/null +++ b/aiortc/src/redux/reducers/dataConsumers.ts @@ -0,0 +1,42 @@ +const initialState = {}; + +const dataConsumers = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + return {}; + else + return state; + } + + case 'ADD_DATA_CONSUMER': + { + const { dataConsumer } = action.payload; + + return { ...state, [dataConsumer.id]: dataConsumer }; + } + + case 'REMOVE_DATA_CONSUMER': + { + const { dataConsumerId } = action.payload; + const newState = { ...state }; + + // @ts-ignore + delete newState[dataConsumerId]; + + return newState; + } + + default: + { + return state; + } + } +}; + +export default dataConsumers; diff --git a/aiortc/src/redux/reducers/dataProducers.ts b/aiortc/src/redux/reducers/dataProducers.ts new file mode 100644 index 0000000..757c089 --- /dev/null +++ b/aiortc/src/redux/reducers/dataProducers.ts @@ -0,0 +1,42 @@ +const initialState = {}; + +const dataProducers = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + return {}; + else + return state; + } + + case 'ADD_DATA_PRODUCER': + { + const { dataProducer } = action.payload; + + return { ...state, [dataProducer.id]: dataProducer }; + } + + case 'REMOVE_DATA_PRODUCER': + { + const { dataProducerId } = action.payload; + const newState = { ...state }; + + // @ts-ignore + delete newState[dataProducerId]; + + return newState; + } + + default: + { + return state; + } + } +}; + +export default dataProducers; diff --git a/aiortc/src/redux/reducers/index.ts b/aiortc/src/redux/reducers/index.ts new file mode 100644 index 0000000..ac16241 --- /dev/null +++ b/aiortc/src/redux/reducers/index.ts @@ -0,0 +1,21 @@ +import { combineReducers } from 'redux'; +import room from './room'; +import me from './me'; +import producers from './producers'; +import dataProducers from './dataProducers'; +import peers from './peers'; +import consumers from './consumers'; +import dataConsumers from './dataConsumers'; + +const reducers = combineReducers( + { + room, + me, + producers, + dataProducers, + peers, + consumers, + dataConsumers + }); + +export default reducers; diff --git a/aiortc/src/redux/reducers/me.ts b/aiortc/src/redux/reducers/me.ts new file mode 100644 index 0000000..c845bed --- /dev/null +++ b/aiortc/src/redux/reducers/me.ts @@ -0,0 +1,140 @@ +type InitialState = +{ + id: string | null; + displayName: string | null; + displayNameSet: boolean; + device: any | null; + canSendMic: boolean; + canSendWebcam: boolean; + canChangeWebcam: boolean; + webcamInProgress: boolean; + shareInProgress: boolean; + audioOnly: boolean; + audioOnlyInProgress: boolean; + audioMuted: boolean; + restartIceInProgress: boolean; +}; + +const initialState: InitialState = +{ + id : null, + displayName : null, + displayNameSet : false, + device : null, + canSendMic : false, + canSendWebcam : false, + canChangeWebcam : false, + webcamInProgress : false, + shareInProgress : false, + audioOnly : false, + audioOnlyInProgress : false, + audioMuted : false, + restartIceInProgress : false +}; + +const me = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + { + return { + ...state, + webcamInProgress : false, + shareInProgress : false, + audioOnly : false, + audioOnlyInProgress : false, + audioMuted : false, + restartIceInProgress : false + }; + } + else + { + return state; + } + } + + case 'SET_ME': + { + const { peerId, displayName, displayNameSet, device } = action.payload; + + return { ...state, id: peerId, displayName, displayNameSet, device }; + } + + case 'SET_MEDIA_CAPABILITIES': + { + const { canSendMic, canSendWebcam } = action.payload; + + return { ...state, canSendMic, canSendWebcam }; + } + + case 'SET_CAN_CHANGE_WEBCAM': + { + const canChangeWebcam = action.payload; + + return { ...state, canChangeWebcam }; + } + + case 'SET_WEBCAM_IN_PROGRESS': + { + const { flag } = action.payload; + + return { ...state, webcamInProgress: flag }; + } + + case 'SET_SHARE_IN_PROGRESS': + { + const { flag } = action.payload; + + return { ...state, shareInProgress: flag }; + } + + case 'SET_DISPLAY_NAME': + { + let { displayName } = action.payload; + + // Be ready for undefined displayName (so keep previous one). + if (!displayName) + displayName = state.displayName; + + return { ...state, displayName, displayNameSet: true }; + } + + case 'SET_AUDIO_ONLY_STATE': + { + const { enabled } = action.payload; + + return { ...state, audioOnly: enabled }; + } + + case 'SET_AUDIO_ONLY_IN_PROGRESS': + { + const { flag } = action.payload; + + return { ...state, audioOnlyInProgress: flag }; + } + + case 'SET_AUDIO_MUTED_STATE': + { + const { enabled } = action.payload; + + return { ...state, audioMuted: enabled }; + } + + case 'SET_RESTART_ICE_IN_PROGRESS': + { + const { flag } = action.payload; + + return { ...state, restartIceInProgress: flag }; + } + + default: + return state; + } +}; + +export default me; diff --git a/aiortc/src/redux/reducers/peers.ts b/aiortc/src/redux/reducers/peers.ts new file mode 100644 index 0000000..5f1c83a --- /dev/null +++ b/aiortc/src/redux/reducers/peers.ts @@ -0,0 +1,144 @@ +const initialState = {}; + +const peers = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + return {}; + else + return state; + } + + case 'ADD_PEER': + { + const { peer } = action.payload; + + return { ...state, [peer.id]: peer }; + } + + case 'REMOVE_PEER': + { + const { peerId } = action.payload; + const newState = { ...state }; + // @ts-ignore + + delete newState[peerId]; + + return newState; + } + + case 'SET_PEER_DISPLAY_NAME': + { + const { displayName, peerId } = action.payload; + // @ts-ignore + const peer = state[peerId]; + + if (!peer) + throw new Error('no Peer found'); + + const newPeer = { ...peer, displayName }; + + return { ...state, [newPeer.id]: newPeer }; + } + + case 'ADD_CONSUMER': + { + const { consumer, peerId } = action.payload; + // @ts-ignore + const peer = state[peerId]; + + if (!peer) + throw new Error('no Peer found for new Consumer'); + + const newConsumers = [ ...peer.consumers, consumer.id ]; + const newPeer = { ...peer, consumers: newConsumers }; + + return { ...state, [newPeer.id]: newPeer }; + } + + case 'REMOVE_CONSUMER': + { + const { consumerId, peerId } = action.payload; + // @ts-ignore + const peer = state[peerId]; + + // NOTE: This means that the Peer was closed before, so it's ok. + if (!peer) + return state; + + const idx = peer.consumers.indexOf(consumerId); + + if (idx === -1) + throw new Error('Consumer not found'); + + const newConsumers = peer.consumers.slice(); + + newConsumers.splice(idx, 1); + + const newPeer = { ...peer, consumers: newConsumers }; + + return { ...state, [newPeer.id]: newPeer }; + } + + case 'ADD_DATA_CONSUMER': + { + const { dataConsumer, peerId } = action.payload; + + // special case for bot DataConsumer. + if (!peerId) + return state; + + // @ts-ignore + const peer = state[peerId]; + + if (!peer) + throw new Error('no Peer found for new DataConsumer'); + + const newDataConsumers = [ ...peer.dataConsumers, dataConsumer.id ]; + const newPeer = { ...peer, dataConsumers: newDataConsumers }; + + return { ...state, [newPeer.id]: newPeer }; + } + + case 'REMOVE_DATA_CONSUMER': + { + const { dataConsumerId, peerId } = action.payload; + + // special case for bot DataConsumer. + if (!peerId) + return state; + + // @ts-ignore + const peer = state[peerId]; + + // NOTE: This means that the Peer was closed before, so it's ok. + if (!peer) + return state; + + const idx = peer.dataConsumers.indexOf(dataConsumerId); + + if (idx === -1) + throw new Error('DataConsumer not found'); + + const newDataConsumers = peer.dataConsumers.slice(); + + newDataConsumers.splice(idx, 1); + + const newPeer = { ...peer, dataConsumers: newDataConsumers }; + + return { ...state, [newPeer.id]: newPeer }; + } + + default: + { + return state; + } + } +}; + +export default peers; diff --git a/aiortc/src/redux/reducers/producers.ts b/aiortc/src/redux/reducers/producers.ts new file mode 100644 index 0000000..07ad2b8 --- /dev/null +++ b/aiortc/src/redux/reducers/producers.ts @@ -0,0 +1,86 @@ +const initialState = {}; + +const producers = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'closed') + return {}; + else + return state; + } + + case 'ADD_PRODUCER': + { + const { producer } = action.payload; + + return { ...state, [producer.id]: producer }; + } + + case 'REMOVE_PRODUCER': + { + const { producerId } = action.payload; + const newState = { ...state }; + + // @ts-ignore + delete newState[producerId]; + + return newState; + } + + case 'SET_PRODUCER_PAUSED': + { + const { producerId } = action.payload; + // @ts-ignore + const producer = state[producerId]; + const newProducer = { ...producer, paused: true }; + + return { ...state, [producerId]: newProducer }; + } + + case 'SET_PRODUCER_RESUMED': + { + const { producerId } = action.payload; + // @ts-ignore + const producer = state[producerId]; + const newProducer = { ...producer, paused: false }; + + return { ...state, [producerId]: newProducer }; + } + + case 'SET_PRODUCER_TRACK': + { + const { producerId, track } = action.payload; + // @ts-ignore + const producer = state[producerId]; + const newProducer = { ...producer, track }; + + return { ...state, [producerId]: newProducer }; + } + + case 'SET_PRODUCER_SCORE': + { + const { producerId, score } = action.payload; + // @ts-ignore + const producer = state[producerId]; + + if (!producer) + return state; + + const newProducer = { ...producer, score }; + + return { ...state, [producerId]: newProducer }; + } + + default: + { + return state; + } + } +}; + +export default producers; diff --git a/aiortc/src/redux/reducers/room.ts b/aiortc/src/redux/reducers/room.ts new file mode 100644 index 0000000..61908ee --- /dev/null +++ b/aiortc/src/redux/reducers/room.ts @@ -0,0 +1,83 @@ +type InitialState = +{ + url: string | null; + state: 'new' | 'connecting' | 'connected' | 'disconnected' | 'closed'; + activeSpeakerId: string | null; + statsPeerId: string | null; + faceDetection: boolean; +}; + +const initialState: InitialState = +{ + url : null, + state : 'new', + activeSpeakerId : null, + statsPeerId : null, + faceDetection : false +}; + +const room = (state = initialState, action: any): any => +{ + switch (action.type) + { + case 'SET_ROOM_URL': + { + const { url } = action.payload; + + return { ...state, url }; + } + + case 'SET_ROOM_STATE': + { + const roomState = action.payload.state; + + if (roomState === 'connected') + return { ...state, state: roomState }; + else + return { ...state, state: roomState, activeSpeakerId: null, statsPeerId: null }; + } + + case 'SET_ROOM_ACTIVE_SPEAKER': + { + const { peerId } = action.payload; + + return { ...state, activeSpeakerId: peerId }; + } + + case 'SET_ROOM_STATS_PEER_ID': + { + const { peerId } = action.payload; + + if (state.statsPeerId === peerId) + return { ...state, statsPeerId: null }; + + return { ...state, statsPeerId: peerId }; + } + + case 'SET_FACE_DETECTION': + { + const flag = action.payload; + + return { ...state, faceDetection: flag }; + } + + case 'REMOVE_PEER': + { + const { peerId } = action.payload; + const newState = { ...state }; + + if (peerId && peerId === state.activeSpeakerId) + newState.activeSpeakerId = null; + + if (peerId && peerId === state.statsPeerId) + newState.statsPeerId = null; + + return newState; + } + + default: + return state; + } +}; + +export default room; diff --git a/aiortc/src/redux/stateActions.ts b/aiortc/src/redux/stateActions.ts new file mode 100644 index 0000000..2d1a684 --- /dev/null +++ b/aiortc/src/redux/stateActions.ts @@ -0,0 +1,326 @@ +export const setRoomUrl = (url: string): any => +{ + return { + type : 'SET_ROOM_URL', + payload : { url } + }; +}; + +export const setRoomState = (state: string): any => +{ + return { + type : 'SET_ROOM_STATE', + payload : { state } + }; +}; + +export const setRoomActiveSpeaker = (peerId: string): any => +{ + return { + type : 'SET_ROOM_ACTIVE_SPEAKER', + payload : { peerId } + }; +}; + +export const setRoomStatsPeerId = (peerId: string): any => +{ + return { + type : 'SET_ROOM_STATS_PEER_ID', + payload : { peerId } + }; +}; + +export const setRoomFaceDetection = (flag: boolean): any => +{ + return { + type : 'SET_FACE_DETECTION', + payload : flag + }; +}; + +export const setMe = ( + { peerId, displayName, displayNameSet, device }: + { peerId: string; displayName: string; displayNameSet: boolean; device: any } +): any => +{ + return { + type : 'SET_ME', + payload : { peerId, displayName, displayNameSet, device } + }; +}; + +export const setMediaCapabilities = ( + { canSendMic, canSendWebcam }: + { canSendMic: boolean; canSendWebcam: boolean } +): any => +{ + return { + type : 'SET_MEDIA_CAPABILITIES', + payload : { canSendMic, canSendWebcam } + }; +}; + +export const setCanChangeWebcam = (flag: boolean): any => +{ + return { + type : 'SET_CAN_CHANGE_WEBCAM', + payload : flag + }; +}; + +export const setDisplayName = (displayName: string): any => +{ + return { + type : 'SET_DISPLAY_NAME', + payload : { displayName } + }; +}; + +export const setAudioOnlyState = (enabled: boolean): any => +{ + return { + type : 'SET_AUDIO_ONLY_STATE', + payload : { enabled } + }; +}; + +export const setAudioOnlyInProgress = (flag: boolean): any => +{ + return { + type : 'SET_AUDIO_ONLY_IN_PROGRESS', + payload : { flag } + }; +}; + +export const setAudioMutedState = (enabled: boolean): any => +{ + return { + type : 'SET_AUDIO_MUTED_STATE', + payload : { enabled } + }; +}; + +export const setRestartIceInProgress = (flag: boolean): any => +{ + return { + type : 'SET_RESTART_ICE_IN_PROGRESS', + payload : { flag } + }; +}; + +export const addProducer = (producer: any): any => +{ + return { + type : 'ADD_PRODUCER', + payload : { producer } + }; +}; + +export const removeProducer = (producerId: string): any => +{ + return { + type : 'REMOVE_PRODUCER', + payload : { producerId } + }; +}; + +export const setProducerPaused = (producerId: string): any => +{ + return { + type : 'SET_PRODUCER_PAUSED', + payload : { producerId } + }; +}; + +export const setProducerResumed = (producerId: string): any => +{ + return { + type : 'SET_PRODUCER_RESUMED', + payload : { producerId } + }; +}; + +export const setProducerTrack = (producerId: string, track: any): any => +{ + return { + type : 'SET_PRODUCER_TRACK', + payload : { producerId, track } + }; +}; + +export const setProducerScore = (producerId: any, score: number): any => +{ + return { + type : 'SET_PRODUCER_SCORE', + payload : { producerId, score } + }; +}; + +export const addDataProducer = (dataProducer: any): any => +{ + return { + type : 'ADD_DATA_PRODUCER', + payload : { dataProducer } + }; +}; + +export const removeDataProducer = (dataProducerId: string): any => +{ + return { + type : 'REMOVE_DATA_PRODUCER', + payload : { dataProducerId } + }; +}; + +export const setWebcamInProgress = (flag: boolean): any => +{ + return { + type : 'SET_WEBCAM_IN_PROGRESS', + payload : { flag } + }; +}; + +export const setShareInProgress = (flag: boolean): any => +{ + return { + type : 'SET_SHARE_IN_PROGRESS', + payload : { flag } + }; +}; + +export const addPeer = (peer: any): any => +{ + return { + type : 'ADD_PEER', + payload : { peer } + }; +}; + +export const removePeer = (peerId: string): any => +{ + return { + type : 'REMOVE_PEER', + payload : { peerId } + }; +}; + +export const setPeerDisplayName = (displayName: string, peerId: string): any => +{ + return { + type : 'SET_PEER_DISPLAY_NAME', + payload : { displayName, peerId } + }; +}; + +export const addConsumer = (consumer: any, peerId: string): any => +{ + return { + type : 'ADD_CONSUMER', + payload : { consumer, peerId } + }; +}; + +export const removeConsumer = (consumerId: string, peerId: string): any => +{ + return { + type : 'REMOVE_CONSUMER', + payload : { consumerId, peerId } + }; +}; + +export const setConsumerPaused = (consumerId: string, originator: string): any => +{ + return { + type : 'SET_CONSUMER_PAUSED', + payload : { consumerId, originator } + }; +}; + +export const setConsumerResumed = (consumerId: string, originator: string): any => +{ + return { + type : 'SET_CONSUMER_RESUMED', + payload : { consumerId, originator } + }; +}; + +export const setConsumerCurrentLayers = + (consumerId: string, spatialLayer: number, temporalLayer: number): any => + { + return { + type : 'SET_CONSUMER_CURRENT_LAYERS', + payload : { consumerId, spatialLayer, temporalLayer } + }; + }; + +export const setConsumerPreferredLayers = + (consumerId: string, spatialLayer: number, temporalLayer: number): any => + { + return { + type : 'SET_CONSUMER_PREFERRED_LAYERS', + payload : { consumerId, spatialLayer, temporalLayer } + }; + }; + +export const setConsumerPriority = (consumerId: string, priority: number): any => +{ + return { + type : 'SET_CONSUMER_PRIORITY', + payload : { consumerId, priority } + }; +}; + +export const setConsumerTrack = (consumerId: string, track: any): any => +{ + return { + type : 'SET_CONSUMER_TRACK', + payload : { consumerId, track } + }; +}; + +export const setConsumerScore = (consumerId: string, score: number): any => +{ + return { + type : 'SET_CONSUMER_SCORE', + payload : { consumerId, score } + }; +}; + +export const addDataConsumer = (dataConsumer: any, peerId: string): any => +{ + return { + type : 'ADD_DATA_CONSUMER', + payload : { dataConsumer, peerId } + }; +}; + +export const removeDataConsumer = (dataConsumerId: string, peerId: string): any => +{ + return { + type : 'REMOVE_DATA_CONSUMER', + payload : { dataConsumerId, peerId } + }; +}; + +export const addNotification = (notification: any): any => +{ + return { + type : 'ADD_NOTIFICATION', + payload : { notification } + }; +}; + +export const removeNotification = (notificationId: string): any => +{ + return { + type : 'REMOVE_NOTIFICATION', + payload : { notificationId } + }; +}; + +export const removeAllNotifications = (): any => +{ + return { + type : 'REMOVE_ALL_NOTIFICATIONS' + }; +}; diff --git a/aiortc/src/urlFactory.ts b/aiortc/src/urlFactory.ts new file mode 100644 index 0000000..d2cf1b8 --- /dev/null +++ b/aiortc/src/urlFactory.ts @@ -0,0 +1,14 @@ +let protooPort = 4443; + +const hostname = process.env.HOSTNAME || 'test.mediasoup.org'; + +if (hostname === 'test.mediasoup.org') + protooPort = 4444; + +export function getProtooUrl( + { roomId, peerId }: + { roomId: string; peerId: string; } +): string +{ + return `wss://${hostname}:${protooPort}/?roomId=${roomId}&peerId=${peerId}`; +} diff --git a/aiortc/tsconfig.json b/aiortc/tsconfig.json new file mode 100644 index 0000000..0445f07 --- /dev/null +++ b/aiortc/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compileOnSave": true, + "compilerOptions": + { + "lib": [ "es2018", "dom" ], + "target": "es2018", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "outDir": "lib", + "strict": true, + "strictNullChecks": false, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny" : true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": false + }, + "include": [ "src" ] +} diff --git a/app/.babelrc b/app/.babelrc new file mode 100644 index 0000000..20714a4 --- /dev/null +++ b/app/.babelrc @@ -0,0 +1,13 @@ +{ + "plugins": + [ + "@babel/plugin-transform-runtime", + "@babel/plugin-proposal-object-rest-spread", + "jsx-control-statements" + ], + "presets": + [ + "@babel/env", + "@babel/react" + ] +} diff --git a/app/.eslintrc.js b/app/.eslintrc.js new file mode 100644 index 0000000..ff7a272 --- /dev/null +++ b/app/.eslintrc.js @@ -0,0 +1,236 @@ +module.exports = +{ + env: + { + browser: true, + es6: true, + node: true + }, + plugins: + [ + 'import', + 'react', + 'jsx-control-statements' + ], + extends: + [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:jsx-control-statements/recommended' + ], + settings: + { + react: + { + pragma: 'React', + version: '16' + } + }, + parserOptions: + { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: + { + impliedStrict: true, + jsx: true + } + }, + rules: + { + 'array-bracket-spacing': [ 2, 'always', + { + objectsInArrays: true, + arraysInArrays: true + }], + 'arrow-parens': [ 2, 'always' ], + 'arrow-spacing': 2, + 'block-spacing': [ 2, 'always' ], + 'brace-style': [ 2, 'allman', { allowSingleLine: true } ], + 'camelcase': 2, + 'comma-dangle': 2, + 'comma-spacing': [ 2, { before: false, after: true } ], + 'comma-style': 2, + 'computed-property-spacing': 2, + 'constructor-super': 2, + 'func-call-spacing': 2, + 'generator-star-spacing': 2, + 'guard-for-in': 2, + 'indent': [ 2, 'tab', { 'SwitchCase': 1 } ], + 'key-spacing': [ 2, + { + singleLine: + { + beforeColon: false, + afterColon: true + }, + multiLine: + { + beforeColon: true, + afterColon: true, + align: 'colon' + } + }], + 'keyword-spacing': 2, + 'linebreak-style': [ 2, 'windows' ], + 'lines-around-comment': [ 2, + { + allowBlockStart: true, + allowObjectStart: true, + beforeBlockComment: true, + beforeLineComment: false + }], + 'max-len': [ 2, 94, + { + tabWidth: 2, + comments: 110, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true + }], + 'newline-after-var': 2, + 'newline-before-return': 2, + 'newline-per-chained-call': 2, + 'no-alert': 2, + 'no-caller': 2, + 'no-case-declarations': 2, + 'no-catch-shadow': 2, + 'no-class-assign': 2, + 'no-confusing-arrow': 2, + 'no-console': 2, + 'no-const-assign': 2, + 'no-debugger': 2, + 'no-dupe-args': 2, + 'no-dupe-keys': 2, + 'no-duplicate-case': 2, + 'no-div-regex': 2, + 'no-empty': [ 2, { allowEmptyCatch: true } ], + 'no-empty-pattern': 2, + 'no-else-return': 0, + 'no-eval': 2, + 'no-extend-native': 2, + 'no-ex-assign': 2, + 'no-extra-bind': 2, + 'no-extra-boolean-cast': 2, + 'no-extra-label': 2, + 'no-extra-semi': 2, + 'no-fallthrough': 2, + 'no-func-assign': 2, + 'no-global-assign': 2, + 'no-implicit-coercion': 2, + 'no-implicit-globals': 2, + 'no-inner-declarations': 2, + 'no-invalid-regexp': 2, + 'no-invalid-this': 2, + 'no-irregular-whitespace': 2, + 'no-lonely-if': 2, + 'no-mixed-operators': 2, + 'no-mixed-spaces-and-tabs': 2, + 'no-multi-spaces': 2, + 'no-multi-str': 2, + 'no-multiple-empty-lines': [ 2, { max: 1, maxEOF: 0, maxBOF: 0 } ], + 'no-native-reassign': 2, + 'no-negated-in-lhs': 2, + 'no-new': 2, + 'no-new-func': 2, + 'no-new-wrappers': 2, + 'no-obj-calls': 2, + 'no-proto': 2, + 'no-prototype-builtins': 0, + 'no-redeclare': 2, + 'no-regex-spaces': 2, + 'no-restricted-imports': 2, + 'no-return-assign': 2, + 'no-self-assign': 2, + 'no-self-compare': 2, + 'no-sequences': 2, + 'no-shadow': 2, + 'no-shadow-restricted-names': 2, + 'no-spaced-func': 2, + 'no-sparse-arrays': 2, + 'no-this-before-super': 2, + 'no-throw-literal': 2, + 'no-undef': 2, + 'no-unexpected-multiline': 2, + 'no-unmodified-loop-condition': 2, + 'no-unreachable': 2, + 'no-unused-vars': [ 1, { vars: 'all', args: 'after-used' }], + 'no-use-before-define': [ 2, { functions: false } ], + 'no-useless-call': 2, + 'no-useless-computed-key': 2, + 'no-useless-concat': 2, + 'no-useless-rename': 2, + 'no-var': 2, + 'no-whitespace-before-property': 2, + 'object-curly-newline': 0, + 'object-curly-spacing': [ 2, 'always' ], + 'object-property-newline': [ 2, { allowMultiplePropertiesPerLine: true } ], + 'prefer-const': 2, + 'prefer-rest-params': 2, + 'prefer-spread': 2, + 'prefer-template': 2, + 'quotes': [ 2, 'single', { avoidEscape: true } ], + 'semi': [ 2, 'always' ], + 'semi-spacing': 2, + 'space-before-blocks': 2, + 'space-before-function-paren': [ 2, + { + anonymous : 'never', + named : 'never', + asyncArrow : 'always' + }], + 'space-in-parens': [ 2, 'never' ], + 'spaced-comment': [ 2, 'always' ], + 'strict': 2, + 'valid-typeof': 2, + 'eol-last': 2, + 'yoda': 2, + // eslint-plugin-import options. + 'import/extensions': 2, + 'import/no-duplicates': 2, + // eslint-plugin-react options. + 'jsx-quotes': [ 2, 'prefer-single' ], + 'react/display-name': [ 2, { ignoreTranspilerName: false } ], + 'react/forbid-prop-types': 0, + 'react/jsx-boolean-value': 2, + 'react/jsx-closing-bracket-location': 2, + 'react/jsx-curly-spacing': 2, + 'react/jsx-equals-spacing': 2, + 'react/jsx-handler-names': 2, + 'react/jsx-indent-props': [ 2, 'tab' ], + 'react/jsx-indent': [ 2, 'tab' ], + 'react/jsx-key': 2, + 'react/jsx-max-props-per-line': 0, + 'react/jsx-no-bind': 0, + 'react/jsx-no-duplicate-props': 2, + 'react/jsx-no-literals': 0, + 'react/jsx-no-undef': 0, + 'react/jsx-pascal-case': 2, + 'react/jsx-sort-prop-types': 0, + 'react/jsx-sort-props': 0, + 'react/jsx-uses-react': 2, + 'react/jsx-uses-vars': 2, + 'react/no-danger': 2, + 'react/no-deprecated': 2, + 'react/no-did-mount-set-state': 2, + 'react/no-did-update-set-state': 2, + 'react/no-direct-mutation-state': 2, + 'react/no-is-mounted': 2, + 'react/no-multi-comp': 0, + 'react/no-set-state': 0, + 'react/no-string-refs': 0, + 'react/no-unknown-property': 2, + 'react/prefer-es6-class': 2, + 'react/prop-types': [ 2, { skipUndeclared: true } ], + 'react/react-in-jsx-scope': 2, + 'react/self-closing-comp': 2, + 'react/sort-comp': 0, + 'react/jsx-wrap-multilines': [ 2, + { + declaration: false, + assignment: false, + return: true + }] + } +}; diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/app/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/app/banner.txt b/app/banner.txt new file mode 100644 index 0000000..3de4791 --- /dev/null +++ b/app/banner.txt @@ -0,0 +1,7 @@ +/* + * <%= pkg.name %> v<%= pkg.version %> + * <%= pkg.description %> + * Copyright: 2017-<%= currentYear %> <%= pkg.author %> + * License: <%= pkg.license %> + */ + diff --git a/app/gulpfile.js b/app/gulpfile.js new file mode 100644 index 0000000..7129c3f --- /dev/null +++ b/app/gulpfile.js @@ -0,0 +1,454 @@ +/** + * Tasks: + * + * gulp dist + * Generates the browser app in development mode (unless NODE_ENV is set + * to 'production'). + * + * gulp live + * Generates the browser app in development mode (unless NODE_ENV is set + * to 'production'), opens it and watches for changes in the source code. + * + * gulp devel + * Generates the browser app in development mode (unless NODE_ENV is set + * to 'production'), opens two browsers and watches for changes in the source + * code. + * + * gulp devel:tcp + * Same as gulp devel, but forcing media over TCP. + * + * gulp devel:vp9 + * Generates the browser app in development mode (unless NODE_ENV is set + * to 'production'), opens two browsers forcing VP9 and watches for changes in + * the source code. + * + * gulp devel:h264 + * Generates the browser app in development mode (unless NODE_ENV is set + * to 'production'), opens two browsers forcing H264 and watches for changes in + * the source code. + + * gulp + * Alias for `gulp dist`. + */ + +const fs = require('fs'); +const path = require('path'); +const gulp = require('gulp'); +const gulpif = require('gulp-if'); +const gutil = require('gulp-util'); +const plumber = require('gulp-plumber'); +const rename = require('gulp-rename'); +const header = require('gulp-header'); +const touch = require('gulp-touch-cmd'); +const browserify = require('browserify'); +const watchify = require('watchify'); +const envify = require('envify/custom'); +const uglify = require('gulp-uglify-es').default; +const source = require('vinyl-source-stream'); +const buffer = require('vinyl-buffer'); +const del = require('del'); +const mkdirp = require('mkdirp'); +const ncp = require('ncp'); +const eslint = require('gulp-eslint'); +const stylus = require('gulp-stylus'); +const cssBase64 = require('gulp-css-base64'); +const nib = require('nib'); +const browserSync = require('browser-sync'); + +const PKG = require('./package'); +const BANNER = fs.readFileSync('banner.txt').toString(); +const BANNER_OPTIONS = +{ + pkg : PKG, + currentYear : (new Date()).getFullYear() +}; +const OUTPUT_DIR = '../server/public'; + +// Set Node 'development' environment (unless externally set). +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +gutil.log(`NODE_ENV: ${process.env.NODE_ENV}`); + +function logError(error) +{ + gutil.log(gutil.colors.red(error.stack)); +} + +function bundle(options) +{ + options = options || {}; + + const watch = Boolean(options.watch); + + let bundler = browserify( + { + entries : PKG.main, + extensions : [ '.js', '.jsx' ], + // required for sourcemaps (must be false otherwise). + debug : process.env.NODE_ENV === 'development', + // required for watchify. + cache : {}, + // required for watchify. + packageCache : {}, + // required to be true only for watchify. + fullPaths : watch + }) + .transform('babelify') + .transform(envify( + { + NODE_ENV : process.env.NODE_ENV, + _ : 'purge' + })); + + if (watch) + { + bundler = watchify(bundler); + + bundler.on('update', () => + { + const start = Date.now(); + + gutil.log('bundling...'); + rebundle(); + gutil.log('bundle took %sms', (Date.now() - start)); + }); + } + + function rebundle() + { + return bundler.bundle() + .on('error', logError) + .pipe(plumber()) + .pipe(source(`${PKG.name}.js`)) + .pipe(buffer()) + .pipe(rename(`${PKG.name}.js`)) + .pipe(gulpif(process.env.NODE_ENV === 'production', + uglify() + )) + .pipe(header(BANNER, BANNER_OPTIONS)) + .pipe(gulp.dest(OUTPUT_DIR)); + } + + return rebundle(); +} + +gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); + +gulp.task('lint', () => +{ + const src = + [ + 'gulpfile.js', + 'lib/**/*.js', + 'lib/**/*.jsx' + ]; + + return gulp.src(src) + .pipe(plumber()) + .pipe(eslint()) + .pipe(eslint.format()); +}); + +gulp.task('css', () => +{ + return gulp.src('stylus/index.styl') + .pipe(plumber()) + .pipe(stylus( + { + use : nib(), + compress : process.env.NODE_ENV === 'production' + })) + .on('error', logError) + .pipe(cssBase64( + { + baseDir : '.', + maxWeightResource : 50000 // So big ttf fonts are not included, nice. + })) + .pipe(rename(`${PKG.name}.css`)) + .pipe(gulp.dest(OUTPUT_DIR)) + .pipe(touch()); +}); + +gulp.task('html', () => +{ + return gulp.src('index.html') + .pipe(gulp.dest(OUTPUT_DIR)); +}); + +gulp.task('resources', (done) => +{ + const dst = path.join(OUTPUT_DIR, 'resources'); + + mkdirp.sync(dst); + ncp('resources', dst, { stopOnErr: true }, (error) => + { + if (error && error[0].code !== 'ENOENT') + throw new Error(`resources copy failed: ${error}`); + + done(); + }); +}); + +gulp.task('bundle', () => +{ + return bundle({ watch: false }); +}); + +gulp.task('bundle:watch', () => +{ + return bundle({ watch: true }); +}); + +gulp.task('dist', gulp.series( + 'clean', + 'lint', + 'bundle', + 'html', + 'css', + 'resources' +)); + +gulp.task('watch', (done) => +{ + // Watch changes in HTML. + gulp.watch([ 'index.html' ], gulp.series( + 'html' + )); + + // Watch changes in Stylus files. + gulp.watch([ 'stylus/**/*.styl' ], gulp.series( + 'css' + )); + + // Watch changes in resources. + gulp.watch([ 'resources/**/*' ], gulp.series( + 'resources', 'css' + )); + + // Watch changes in JS files. + gulp.watch([ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ], gulp.series( + 'lint' + )); + + done(); +}); + +gulp.task('browser:base', gulp.series( + 'clean', + 'lint', + 'bundle:watch', + 'html', + 'css', + 'resources', + 'watch' +)); + +gulp.task('live', gulp.series( + 'browser:base', + (done) => + { + const config = require('../server/config'); + + browserSync( + { + open : 'external', + host : config.domain, + startPath : '/?info=true', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }); + + done(); + } +)); + +gulp.task('devel', gulp.series( + 'browser:base', + async (done) => + { + const config = require('../server/config'); + + await new Promise((resolve) => + { + browserSync.create('producer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel&info=true&_throttleSecret=foo&consume=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + await new Promise((resolve) => + { + browserSync.create('consumer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel&info=true&_throttleSecret=foo&produce=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + done(); + } +)); + +gulp.task('devel:tcp', gulp.series( + 'browser:base', + async (done) => + { + const config = require('../server/config'); + + await new Promise((resolve) => + { + browserSync.create('producer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:tcp&info=true&_throttleSecret=foo&forceTcp=true&consume=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + await new Promise((resolve) => + { + browserSync.create('consumer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:tcp&info=true&_throttleSecret=foo&forceTcp=true&produce=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + done(); + } +)); + +gulp.task('devel:vp9', gulp.series( + 'browser:base', + async (done) => + { + const config = require('../server/config'); + + await new Promise((resolve) => + { + browserSync.create('producer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:vp9&info=true&_throttleSecret=foo&forceVP9=true&numSimulcastStreams=3&webcamScalabilityMode=L1T3&consume=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + await new Promise((resolve) => + { + browserSync.create('consumer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:vp9&info=true&_throttleSecret=foo&forceVP9=true&produce=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + done(); + } +)); + +gulp.task('devel:h264', gulp.series( + 'browser:base', + async (done) => + { + const config = require('../server/config'); + + await new Promise((resolve) => + { + browserSync.create('producer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:h264&info=true&_throttleSecret=foo&forceH264=true&consume=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + await new Promise((resolve) => + { + browserSync.create('consumer1').init( + { + open : 'external', + host : config.domain, + startPath : '/?roomId=devel:h264&info=true&_throttleSecret=foo&forceH264=true&produce=false', + server : + { + baseDir : OUTPUT_DIR + }, + https : config.https.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }, + resolve); + }); + + done(); + } +)); + +gulp.task('default', gulp.series('dist')); diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..a654352 --- /dev/null +++ b/app/index.html @@ -0,0 +1,29 @@ + + + + + mediasoup demo + + + + + + + + + + + + +
+
+ + diff --git a/app/lib/Logger.js b/app/lib/Logger.js new file mode 100644 index 0000000..7d1baa1 --- /dev/null +++ b/app/lib/Logger.js @@ -0,0 +1,43 @@ +import debug from 'debug'; + +const APP_NAME = 'mediasoup-demo'; + +export default class Logger +{ + constructor(prefix) + { + if (prefix) + { + this._debug = debug(`${APP_NAME}:${prefix}`); + this._warn = debug(`${APP_NAME}:WARN:${prefix}`); + this._error = debug(`${APP_NAME}:ERROR:${prefix}`); + } + else + { + this._debug = debug(APP_NAME); + this._warn = debug(`${APP_NAME}:WARN`); + this._error = debug(`${APP_NAME}:ERROR`); + } + + /* eslint-disable no-console */ + this._debug.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + /* eslint-enable no-console */ + } + + get debug() + { + return this._debug; + } + + get warn() + { + return this._warn; + } + + get error() + { + return this._error; + } +} diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js new file mode 100644 index 0000000..85794c0 --- /dev/null +++ b/app/lib/RoomClient.js @@ -0,0 +1,2669 @@ +import protooClient from 'protoo-client'; +import * as mediasoupClient from 'mediasoup-client'; +import Logger from './Logger'; +import { getProtooUrl } from './urlFactory'; +import * as cookiesManager from './cookiesManager'; +import * as requestActions from './redux/requestActions'; +import * as stateActions from './redux/stateActions'; +import * as e2e from './e2e'; + +const VIDEO_CONSTRAINS = +{ + qvga : { width: { ideal: 320 }, height: { ideal: 240 } }, + vga : { width: { ideal: 640 }, height: { ideal: 480 } }, + hd : { width: { ideal: 1280 }, height: { ideal: 720 } } +}; + +const PC_PROPRIETARY_CONSTRAINTS = +{ + // optional : [ { googDscp: true } ] +}; + +const EXTERNAL_VIDEO_SRC = '/resources/videos/video-audio-stereo.mp4'; + +const logger = new Logger('RoomClient'); + +let store; + +export default class RoomClient +{ + /** + * @param {Object} data + * @param {Object} data.store - The Redux store. + */ + static init(data) + { + store = data.store; + } + + constructor( + { + roomId, + peerId, + displayName, + device, + handlerName, + forceTcp, + produce, + consume, + datachannel, + enableWebcamLayers, + enableSharingLayers, + webcamScalabilityMode, + sharingScalabilityMode, + numSimulcastStreams, + forceVP8, + forceH264, + forceVP9, + externalVideo, + e2eKey, + consumerReplicas + } + ) + { + logger.debug( + 'constructor() [roomId:"%s", peerId:"%s", displayName:"%s", device:%s]', + roomId, peerId, displayName, device.flag); + + // Closed flag. + // @type {Boolean} + this._closed = false; + + // Display name. + // @type {String} + this._displayName = displayName; + + // Device info. + // @type {Object} + this._device = device; + + // Custom mediasoup-client handler name (to override default browser + // detection if desired). + // @type {String} + this._handlerName = handlerName; + + // Whether we want to force RTC over TCP. + // @type {Boolean} + this._forceTcp = forceTcp; + + // Whether we want to produce audio/video. + // @type {Boolean} + this._produce = produce; + + // Whether we should consume. + // @type {Boolean} + this._consume = consume; + + // Whether we want DataChannels. + // @type {Boolean} + this._useDataChannel = Boolean(datachannel); + + // Force VP8 codec for sending. + // @type {Boolean} + this._forceVP8 = Boolean(forceVP8); + + // Force H264 codec for sending. + // @type {Boolean} + this._forceH264 = Boolean(forceH264); + + // Force VP9 codec for sending. + // @type {Boolean} + this._forceVP9 = Boolean(forceVP9); + + // Whether simulcast or SVC should be used for webcam. + // @type {Boolean} + this._enableWebcamLayers = Boolean(enableWebcamLayers); + + // Whether simulcast or SVC should be used in desktop sharing. + // @type {Boolean} + this._enableSharingLayers = Boolean(enableSharingLayers); + + // Scalability mode for webcam. + // @type {String} + this._webcamScalabilityMode = webcamScalabilityMode; + + // Scalability mode for sharing. + // @type {String} + this._sharingScalabilityMode = sharingScalabilityMode; + + // Number of simuclast streams for webcam and sharing. + // @type {Number} + this._numSimulcastStreams = numSimulcastStreams; + + // External video. + // @type {HTMLVideoElement} + this._externalVideo = null; + + // Enabled end-to-end encryption. + this._e2eKey = e2eKey; + + // MediaStream of the external video. + // @type {MediaStream} + this._externalVideoStream = null; + + // Next expected dataChannel test number. + // @type {Number} + this._nextDataChannelTestNumber = 0; + + if (externalVideo) + { + this._externalVideo = document.createElement('video'); + + this._externalVideo.controls = true; + this._externalVideo.muted = true; + this._externalVideo.loop = true; + this._externalVideo.setAttribute('playsinline', ''); + this._externalVideo.src = EXTERNAL_VIDEO_SRC; + + this._externalVideo.play() + .catch((error) => logger.warn('externalVideo.play() failed:%o', error)); + } + + // Protoo URL. + // @type {String} + this._protooUrl = getProtooUrl({ roomId, peerId, consumerReplicas }); + + // protoo-client Peer instance. + // @type {protooClient.Peer} + this._protoo = null; + + // mediasoup-client Device instance. + // @type {mediasoupClient.Device} + this._mediasoupDevice = null; + + // mediasoup Transport for sending. + // @type {mediasoupClient.Transport} + this._sendTransport = null; + + // mediasoup Transport for receiving. + // @type {mediasoupClient.Transport} + this._recvTransport = null; + + // Local mic mediasoup Producer. + // @type {mediasoupClient.Producer} + this._micProducer = null; + + // Local webcam mediasoup Producer. + // @type {mediasoupClient.Producer} + this._webcamProducer = null; + + // Local share mediasoup Producer. + // @type {mediasoupClient.Producer} + this._shareProducer = null; + + // Local chat DataProducer. + // @type {mediasoupClient.DataProducer} + this._chatDataProducer = null; + + // Local bot DataProducer. + // @type {mediasoupClient.DataProducer} + this._botDataProducer = null; + + // mediasoup Consumers. + // @type {Map} + this._consumers = new Map(); + + // mediasoup DataConsumers. + // @type {Map} + this._dataConsumers = new Map(); + + // Map of webcam MediaDeviceInfos indexed by deviceId. + // @type {Map} + this._webcams = new Map(); + + // Local Webcam. + // @type {Object} with: + // - {MediaDeviceInfo} [device] + // - {String} [resolution] - 'qvga' / 'vga' / 'hd'. + this._webcam = + { + device : null, + resolution : 'hd' + }; + + if (this._e2eKey && e2e.isSupported()) + { + e2e.setCryptoKey('setCryptoKey', this._e2eKey, true); + } + } + + close() + { + if (this._closed) + return; + + this._closed = true; + + logger.debug('close()'); + + // Close protoo Peer + this._protoo.close(); + + // Close mediasoup Transports. + if (this._sendTransport) + this._sendTransport.close(); + + if (this._recvTransport) + this._recvTransport.close(); + + store.dispatch( + stateActions.setRoomState('closed')); + } + + async join() + { + store.dispatch( + stateActions.setMediasoupClientVersion(mediasoupClient.version)); + + const protooTransport = new protooClient.WebSocketTransport(this._protooUrl); + + this._protoo = new protooClient.Peer(protooTransport); + + store.dispatch( + stateActions.setRoomState('connecting')); + + this._protoo.on('open', () => this._joinRoom()); + + this._protoo.on('failed', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'WebSocket connection failed' + })); + }); + + this._protoo.on('disconnected', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'WebSocket disconnected' + })); + + // Close mediasoup Transports. + if (this._sendTransport) + { + this._sendTransport.close(); + this._sendTransport = null; + } + + if (this._recvTransport) + { + this._recvTransport.close(); + this._recvTransport = null; + } + + store.dispatch( + stateActions.setRoomState('closed')); + }); + + this._protoo.on('close', () => + { + if (this._closed) + return; + + this.close(); + }); + + // eslint-disable-next-line no-unused-vars + this._protoo.on('request', async (request, accept, reject) => + { + logger.debug( + 'proto "request" event [method:%s, data:%o]', + request.method, request.data); + + switch (request.method) + { + case 'newConsumer': + { + if (!this._consume) + { + reject(403, 'I do not want to consume'); + + break; + } + + const { + peerId, + producerId, + id, + kind, + rtpParameters, + type, + appData, + producerPaused + } = request.data; + + try + { + const consumer = await this._recvTransport.consume( + { + id, + producerId, + kind, + rtpParameters, + // NOTE: Force streamId to be same in mic and webcam and different + // in screen sharing so libwebrtc will just try to sync mic and + // webcam streams from the same remote peer. + streamId : `${peerId}-${appData.share ? 'share' : 'mic-webcam'}`, + appData : { ...appData, peerId } // Trick. + }); + + if (this._e2eKey && e2e.isSupported()) + { + e2e.setupReceiverTransform(consumer.rtpReceiver); + } + + // Store in the map. + this._consumers.set(consumer.id, consumer); + + consumer.on('transportclose', () => + { + this._consumers.delete(consumer.id); + }); + + const { spatialLayers, temporalLayers } = + mediasoupClient.parseScalabilityMode( + consumer.rtpParameters.encodings[0].scalabilityMode); + + store.dispatch(stateActions.addConsumer( + { + id : consumer.id, + type : type, + locallyPaused : false, + remotelyPaused : producerPaused, + rtpParameters : consumer.rtpParameters, + spatialLayers : spatialLayers, + temporalLayers : temporalLayers, + preferredSpatialLayer : spatialLayers - 1, + preferredTemporalLayer : temporalLayers - 1, + priority : 1, + codec : consumer.rtpParameters.codecs[0].mimeType.split('/')[1], + track : consumer.track + }, + peerId)); + + // We are ready. Answer the protoo request so the server will + // resume this Consumer (which was paused for now if video). + accept(); + + // If audio-only mode is enabled, pause it. + if (consumer.kind === 'video' && store.getState().me.audioOnly) + this._pauseConsumer(consumer); + } + catch (error) + { + logger.error('"newConsumer" request failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error creating a Consumer: ${error}` + })); + + throw error; + } + + break; + } + + case 'newDataConsumer': + { + if (!this._consume) + { + reject(403, 'I do not want to data consume'); + + break; + } + + if (!this._useDataChannel) + { + reject(403, 'I do not want DataChannels'); + + break; + } + + const { + peerId, // NOTE: Null if bot. + dataProducerId, + id, + sctpStreamParameters, + label, + protocol, + appData + } = request.data; + + try + { + const dataConsumer = await this._recvTransport.consumeData( + { + id, + dataProducerId, + sctpStreamParameters, + label, + protocol, + appData : { ...appData, peerId } // Trick. + }); + + // Store in the map. + this._dataConsumers.set(dataConsumer.id, dataConsumer); + + dataConsumer.on('transportclose', () => + { + this._dataConsumers.delete(dataConsumer.id); + }); + + dataConsumer.on('open', () => + { + logger.debug('DataConsumer "open" event'); + }); + + dataConsumer.on('close', () => + { + logger.warn('DataConsumer "close" event'); + + this._dataConsumers.delete(dataConsumer.id); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'DataConsumer closed' + })); + }); + + dataConsumer.on('error', (error) => + { + logger.error('DataConsumer "error" event:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `DataConsumer error: ${error}` + })); + }); + + dataConsumer.on('message', (message) => + { + logger.debug( + 'DataConsumer "message" event [streamId:%d]', + dataConsumer.sctpStreamParameters.streamId); + + // TODO: For debugging. + window.DC_MESSAGE = message; + + if (message instanceof ArrayBuffer) + { + const view = new DataView(message); + const number = view.getUint32(); + + if (number == Math.pow(2, 32) - 1) + { + logger.warn('dataChannelTest finished!'); + + this._nextDataChannelTestNumber = 0; + + return; + } + + if (number > this._nextDataChannelTestNumber) + { + logger.warn( + 'dataChannelTest: %s packets missing', + number - this._nextDataChannelTestNumber); + } + + this._nextDataChannelTestNumber = number + 1; + + return; + } + else if (typeof message !== 'string') + { + logger.warn('ignoring DataConsumer "message" (not a string)'); + + return; + } + + switch (dataConsumer.label) + { + case 'chat': + { + const { peers } = store.getState(); + const peersArray = Object.keys(peers) + .map((_peerId) => peers[_peerId]); + const sendingPeer = peersArray + .find((peer) => peer.dataConsumers.includes(dataConsumer.id)); + + if (!sendingPeer) + { + logger.warn('DataConsumer "message" from unknown peer'); + + break; + } + + store.dispatch(requestActions.notify( + { + title : `${sendingPeer.displayName} says:`, + text : message, + timeout : 5000 + })); + + break; + } + + case 'bot': + { + store.dispatch(requestActions.notify( + { + title : 'Message from Bot:', + text : message, + timeout : 5000 + })); + + break; + } + } + }); + + // TODO: REMOVE + window.DC = dataConsumer; + + store.dispatch(stateActions.addDataConsumer( + { + id : dataConsumer.id, + sctpStreamParameters : dataConsumer.sctpStreamParameters, + label : dataConsumer.label, + protocol : dataConsumer.protocol + }, + peerId)); + + // We are ready. Answer the protoo request. + accept(); + } + catch (error) + { + logger.error('"newDataConsumer" request failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error creating a DataConsumer: ${error}` + })); + + throw error; + } + + break; + } + } + }); + + this._protoo.on('notification', (notification) => + { + logger.debug( + 'proto "notification" event [method:%s, data:%o]', + notification.method, notification.data); + + switch (notification.method) + { + case 'mediasoup-version': + { + const { version } = notification.data; + + store.dispatch( + stateActions.setMediasoupVersion(version)); + + break; + } + + case 'producerScore': + { + const { producerId, score } = notification.data; + + store.dispatch( + stateActions.setProducerScore(producerId, score)); + + break; + } + + case 'newPeer': + { + const peer = notification.data; + + store.dispatch( + stateActions.addPeer( + { ...peer, consumers: [], dataConsumers: [] })); + + store.dispatch(requestActions.notify( + { + text : `${peer.displayName} has joined the room` + })); + + break; + } + + case 'peerClosed': + { + const { peerId } = notification.data; + + store.dispatch( + stateActions.removePeer(peerId)); + + break; + } + + case 'peerDisplayNameChanged': + { + const { peerId, displayName, oldDisplayName } = notification.data; + + store.dispatch( + stateActions.setPeerDisplayName(displayName, peerId)); + + store.dispatch(requestActions.notify( + { + text : `${oldDisplayName} is now ${displayName}` + })); + + break; + } + + case 'downlinkBwe': + { + logger.debug('\'downlinkBwe\' event:%o', notification.data); + + break; + } + + case 'consumerClosed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + consumer.close(); + this._consumers.delete(consumerId); + + const { peerId } = consumer.appData; + + store.dispatch( + stateActions.removeConsumer(consumerId, peerId)); + + break; + } + + case 'consumerPaused': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + consumer.pause(); + + store.dispatch( + stateActions.setConsumerPaused(consumerId, 'remote')); + + break; + } + + case 'consumerResumed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + consumer.resume(); + + store.dispatch( + stateActions.setConsumerResumed(consumerId, 'remote')); + + break; + } + + case 'consumerLayersChanged': + { + const { consumerId, spatialLayer, temporalLayer } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch(stateActions.setConsumerCurrentLayers( + consumerId, spatialLayer, temporalLayer)); + + break; + } + + case 'consumerScore': + { + const { consumerId, score } = notification.data; + + store.dispatch( + stateActions.setConsumerScore(consumerId, score)); + + break; + } + + case 'dataConsumerClosed': + { + const { dataConsumerId } = notification.data; + const dataConsumer = this._dataConsumers.get(dataConsumerId); + + if (!dataConsumer) + break; + + dataConsumer.close(); + this._dataConsumers.delete(dataConsumerId); + + const { peerId } = dataConsumer.appData; + + store.dispatch( + stateActions.removeDataConsumer(dataConsumerId, peerId)); + + break; + } + + case 'activeSpeaker': + { + const { peerId } = notification.data; + + store.dispatch( + stateActions.setRoomActiveSpeaker(peerId)); + + break; + } + + default: + { + logger.error( + 'unknown protoo notification.method "%s"', notification.method); + } + } + }); + } + + async enableMic() + { + logger.debug('enableMic()'); + + if (this._micProducer) + return; + + if (!this._mediasoupDevice.canProduce('audio')) + { + logger.error('enableMic() | cannot produce audio'); + + return; + } + + let track; + + try + { + if (!this._externalVideo) + { + logger.debug('enableMic() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + track = stream.getAudioTracks()[0]; + } + else + { + const stream = await this._getExternalVideoStream(); + + track = stream.getAudioTracks()[0].clone(); + } + + this._micProducer = await this._sendTransport.produce( + { + track, + codecOptions : + { + opusStereo : true, + opusDtx : true, + opusFec : true, + opusNack : true + } + // NOTE: for testing codec selection. + // codec : this._mediasoupDevice.rtpCapabilities.codecs + // .find((codec) => codec.mimeType.toLowerCase() === 'audio/pcma') + }); + + if (this._e2eKey && e2e.isSupported()) + { + e2e.setupSenderTransform(this._micProducer.rtpSender); + } + + store.dispatch(stateActions.addProducer( + { + id : this._micProducer.id, + paused : this._micProducer.paused, + track : this._micProducer.track, + rtpParameters : this._micProducer.rtpParameters, + codec : this._micProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._micProducer.on('transportclose', () => + { + this._micProducer = null; + }); + + this._micProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Microphone disconnected!' + })); + + this.disableMic() + .catch(() => {}); + }); + } + catch (error) + { + logger.error('enableMic() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error enabling microphone: ${error}` + })); + + if (track) + track.stop(); + } + } + + async disableMic() + { + logger.debug('disableMic()'); + + if (!this._micProducer) + return; + + this._micProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._micProducer.id)); + + try + { + await this._protoo.request( + 'closeProducer', { producerId: this._micProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side mic Producer: ${error}` + })); + } + + this._micProducer = null; + } + + async muteMic() + { + logger.debug('muteMic()'); + + this._micProducer.pause(); + + try + { + await this._protoo.request( + 'pauseProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerPaused(this._micProducer.id)); + } + catch (error) + { + logger.error('muteMic() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error pausing server-side mic Producer: ${error}` + })); + } + } + + async unmuteMic() + { + logger.debug('unmuteMic()'); + + this._micProducer.resume(); + + try + { + await this._protoo.request( + 'resumeProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerResumed(this._micProducer.id)); + } + catch (error) + { + logger.error('unmuteMic() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error resuming server-side mic Producer: ${error}` + })); + } + } + + async enableWebcam() + { + logger.debug('enableWebcam()'); + + if (this._webcamProducer) + return; + else if (this._shareProducer) + await this.disableShare(); + + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableWebcam() | cannot produce video'); + + return; + } + + let track; + let device; + + store.dispatch( + stateActions.setWebcamInProgress(true)); + + try + { + if (!this._externalVideo) + { + await this._updateWebcams(); + device = this._webcam.device; + + const { resolution } = this._webcam; + + if (!device) + throw new Error('no webcam devices'); + + logger.debug('enableWebcam() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { ideal: device.deviceId }, + ...VIDEO_CONSTRAINS[resolution] + } + }); + + track = stream.getVideoTracks()[0]; + } + else + { + device = { label: 'external video' }; + + const stream = await this._getExternalVideoStream(); + + track = stream.getVideoTracks()[0].clone(); + } + + let encodings; + let codec; + const codecOptions = + { + videoGoogleStartBitrate : 1000 + }; + + if (this._forceVP8) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/vp8'); + + if (!codec) + { + throw new Error('desired VP8 codec+configuration is not supported'); + } + } + else if (this._forceH264) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/h264'); + + if (!codec) + { + throw new Error('desired H264 codec+configuration is not supported'); + } + } + else if (this._forceVP9) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/vp9'); + + if (!codec) + { + throw new Error('desired VP9 codec+configuration is not supported'); + } + } + + if (this._enableWebcamLayers) + { + // If VP9 is the only available video codec then use SVC. + const firstVideoCodec = this._mediasoupDevice + .rtpCapabilities + .codecs + .find((c) => c.kind === 'video'); + + // VP9 with SVC. + if ( + (this._forceVP9 && codec) || + firstVideoCodec.mimeType.toLowerCase() === 'video/vp9' + ) + { + encodings = + [ + { + maxBitrate : 5000000, + scalabilityMode : this._webcamScalabilityMode || 'L3T3_KEY' + } + ]; + } + // VP8 or H264 with simulcast. + else + { + encodings = + [ + { + scaleResolutionDownBy : 1, + maxBitrate : 5000000, + scalabilityMode : this._webcamScalabilityMode || 'L1T3' + } + ]; + + if (this._numSimulcastStreams > 1) + { + encodings.unshift( + { + scaleResolutionDownBy : 2, + maxBitrate : 1000000, + scalabilityMode : this._webcamScalabilityMode || 'L1T3' + } + ); + } + + if (this._numSimulcastStreams > 2) + { + encodings.unshift( + { + scaleResolutionDownBy : 4, + maxBitrate : 500000, + scalabilityMode : this._webcamScalabilityMode || 'L1T3' + } + ); + } + } + } + + this._webcamProducer = await this._sendTransport.produce( + { + track, + encodings, + codecOptions, + codec + }); + + if (this._e2eKey && e2e.isSupported()) + { + e2e.setupSenderTransform(this._webcamProducer.rtpSender); + } + + store.dispatch(stateActions.addProducer( + { + id : this._webcamProducer.id, + deviceLabel : device.label, + type : this._getWebcamType(device), + paused : this._webcamProducer.paused, + track : this._webcamProducer.track, + rtpParameters : this._webcamProducer.rtpParameters, + codec : this._webcamProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._webcamProducer.on('transportclose', () => + { + this._webcamProducer = null; + }); + + this._webcamProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Webcam disconnected!' + })); + + this.disableWebcam() + .catch(() => {}); + }); + } + catch (error) + { + logger.error('enableWebcam() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error enabling webcam: ${error}` + })); + + if (track) + track.stop(); + } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + } + + async disableWebcam() + { + logger.debug('disableWebcam()'); + + if (!this._webcamProducer) + return; + + this._webcamProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._webcamProducer.id)); + + try + { + await this._protoo.request( + 'closeProducer', { producerId: this._webcamProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side webcam Producer: ${error}` + })); + } + + this._webcamProducer = null; + } + + async changeWebcam() + { + logger.debug('changeWebcam()'); + + store.dispatch( + stateActions.setWebcamInProgress(true)); + + try + { + await this._updateWebcams(); + + const array = Array.from(this._webcams.keys()); + const len = array.length; + const deviceId = + this._webcam.device ? this._webcam.device.deviceId : undefined; + let idx = array.indexOf(deviceId); + + if (idx < len - 1) + idx++; + else + idx = 0; + + this._webcam.device = this._webcams.get(array[idx]); + + logger.debug( + 'changeWebcam() | new selected webcam [device:%o]', + this._webcam.device); + + // Reset video resolution to HD. + this._webcam.resolution = 'hd'; + + if (!this._webcam.device) + throw new Error('no webcam devices'); + + // Closing the current video track before asking for a new one (mobiles do not like + // having both front/back cameras open at the same time). + this._webcamProducer.track.stop(); + + logger.debug('changeWebcam() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { exact: this._webcam.device.deviceId }, + ...VIDEO_CONSTRAINS[this._webcam.resolution] + } + }); + + const track = stream.getVideoTracks()[0]; + + await this._webcamProducer.replaceTrack({ track }); + + store.dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, track)); + } + catch (error) + { + logger.error('changeWebcam() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Could not change webcam: ${error}` + })); + } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + } + + async changeWebcamResolution() + { + logger.debug('changeWebcamResolution()'); + + store.dispatch( + stateActions.setWebcamInProgress(true)); + + try + { + switch (this._webcam.resolution) + { + case 'qvga': + this._webcam.resolution = 'vga'; + break; + case 'vga': + this._webcam.resolution = 'hd'; + break; + case 'hd': + this._webcam.resolution = 'qvga'; + break; + default: + this._webcam.resolution = 'hd'; + } + + logger.debug('changeWebcamResolution() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { exact: this._webcam.device.deviceId }, + ...VIDEO_CONSTRAINS[this._webcam.resolution] + } + }); + + const track = stream.getVideoTracks()[0]; + + await this._webcamProducer.replaceTrack({ track }); + + store.dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, track)); + } + catch (error) + { + logger.error('changeWebcamResolution() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Could not change webcam resolution: ${error}` + })); + } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + } + + async enableShare() + { + logger.debug('enableShare()'); + + if (this._shareProducer) + return; + else if (this._webcamProducer) + await this.disableWebcam(); + + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableShare() | cannot produce video'); + + return; + } + + let track; + + store.dispatch( + stateActions.setShareInProgress(true)); + + try + { + logger.debug('enableShare() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getDisplayMedia( + { + audio : false, + video : + { + displaySurface : 'monitor', + logicalSurface : true, + cursor : true, + width : { max: 1920 }, + height : { max: 1080 }, + frameRate : { max: 30 } + } + }); + + // May mean cancelled (in some implementations). + if (!stream) + { + store.dispatch( + stateActions.setShareInProgress(true)); + + return; + } + + track = stream.getVideoTracks()[0]; + + let encodings; + let codec; + const codecOptions = + { + videoGoogleStartBitrate : 1000 + }; + + if (this._forceVP8) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/vp8'); + + if (!codec) + { + throw new Error('desired VP8 codec+configuration is not supported'); + } + } + else if (this._forceH264) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/h264'); + + if (!codec) + { + throw new Error('desired H264 codec+configuration is not supported'); + } + } + else if (this._forceVP9) + { + codec = this._mediasoupDevice.rtpCapabilities.codecs + .find((c) => c.mimeType.toLowerCase() === 'video/vp9'); + + if (!codec) + { + throw new Error('desired VP9 codec+configuration is not supported'); + } + } + + if (this._enableSharingLayers) + { + // If VP9 is the only available video codec then use SVC. + const firstVideoCodec = this._mediasoupDevice + .rtpCapabilities + .codecs + .find((c) => c.kind === 'video'); + + // VP9 with SVC. + if ( + (this._forceVP9 && codec) || + firstVideoCodec.mimeType.toLowerCase() === 'video/vp9' + ) + { + encodings = + [ + { + maxBitrate : 5000000, + scalabilityMode : this._sharingScalabilityMode || 'L3T3', + dtx : true + } + ]; + } + // VP8 or H264 with simulcast. + else + { + encodings = + [ + { + scaleResolutionDownBy : 1, + maxBitrate : 5000000, + scalabilityMode : this._sharingScalabilityMode || 'L1T3', + dtx : true + } + ]; + + if (this._numSimulcastStreams > 1) + { + encodings.unshift( + { + scaleResolutionDownBy : 2, + maxBitrate : 1000000, + scalabilityMode : this._sharingScalabilityMode || 'L1T3', + dtx : true + } + ); + } + + if (this._numSimulcastStreams > 2) + { + encodings.unshift( + { + scaleResolutionDownBy : 4, + maxBitrate : 500000, + scalabilityMode : this._sharingScalabilityMode || 'L1T3', + dtx : true + } + ); + } + } + } + + this._shareProducer = await this._sendTransport.produce( + { + track, + encodings, + codecOptions, + codec, + appData : + { + share : true + } + }); + + if (this._e2eKey && e2e.isSupported()) + { + e2e.setupSenderTransform(this._shareProducer.rtpSender); + } + + store.dispatch(stateActions.addProducer( + { + id : this._shareProducer.id, + type : 'share', + paused : this._shareProducer.paused, + track : this._shareProducer.track, + rtpParameters : this._shareProducer.rtpParameters, + codec : this._shareProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._shareProducer.on('transportclose', () => + { + this._shareProducer = null; + }); + + this._shareProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Share disconnected!' + })); + + this.disableShare() + .catch(() => {}); + }); + } + catch (error) + { + logger.error('enableShare() | failed:%o', error); + + if (error.name !== 'NotAllowedError') + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error sharing: ${error}` + })); + } + + if (track) + track.stop(); + } + + store.dispatch( + stateActions.setShareInProgress(false)); + } + + async disableShare() + { + logger.debug('disableShare()'); + + if (!this._shareProducer) + return; + + this._shareProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._shareProducer.id)); + + try + { + await this._protoo.request( + 'closeProducer', { producerId: this._shareProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side share Producer: ${error}` + })); + } + + this._shareProducer = null; + } + + async enableAudioOnly() + { + logger.debug('enableAudioOnly()'); + + store.dispatch( + stateActions.setAudioOnlyInProgress(true)); + + this.disableWebcam(); + + for (const consumer of this._consumers.values()) + { + if (consumer.kind !== 'video') + continue; + + this._pauseConsumer(consumer); + } + + store.dispatch( + stateActions.setAudioOnlyState(true)); + + store.dispatch( + stateActions.setAudioOnlyInProgress(false)); + } + + async disableAudioOnly() + { + logger.debug('disableAudioOnly()'); + + store.dispatch( + stateActions.setAudioOnlyInProgress(true)); + + if ( + !this._webcamProducer && + this._produce && + (cookiesManager.getDevices() || {}).webcamEnabled + ) + { + this.enableWebcam(); + } + + for (const consumer of this._consumers.values()) + { + if (consumer.kind !== 'video') + continue; + + this._resumeConsumer(consumer); + } + + store.dispatch( + stateActions.setAudioOnlyState(false)); + + store.dispatch( + stateActions.setAudioOnlyInProgress(false)); + } + + async muteAudio() + { + logger.debug('muteAudio()'); + + store.dispatch( + stateActions.setAudioMutedState(true)); + } + + async unmuteAudio() + { + logger.debug('unmuteAudio()'); + + store.dispatch( + stateActions.setAudioMutedState(false)); + } + + async restartIce() + { + logger.debug('restartIce()'); + + store.dispatch( + stateActions.setRestartIceInProgress(true)); + + try + { + if (this._sendTransport) + { + const iceParameters = await this._protoo.request( + 'restartIce', + { transportId: this._sendTransport.id }); + + await this._sendTransport.restartIce({ iceParameters }); + } + + if (this._recvTransport) + { + const iceParameters = await this._protoo.request( + 'restartIce', + { transportId: this._recvTransport.id }); + + await this._recvTransport.restartIce({ iceParameters }); + } + + store.dispatch(requestActions.notify( + { + text : 'ICE restarted' + })); + } + catch (error) + { + logger.error('restartIce() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `ICE restart failed: ${error}` + })); + } + + store.dispatch( + stateActions.setRestartIceInProgress(false)); + } + + async setMaxSendingSpatialLayer(spatialLayer) + { + logger.debug('setMaxSendingSpatialLayer() [spatialLayer:%s]', spatialLayer); + + try + { + if (this._webcamProducer) + await this._webcamProducer.setMaxSpatialLayer(spatialLayer); + else if (this._shareProducer) + await this._shareProducer.setMaxSpatialLayer(spatialLayer); + } + catch (error) + { + logger.error('setMaxSendingSpatialLayer() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error setting max sending video spatial layer: ${error}` + })); + } + } + + async setConsumerPreferredLayers(consumerId, spatialLayer, temporalLayer) + { + logger.debug( + 'setConsumerPreferredLayers() [consumerId:%s, spatialLayer:%s, temporalLayer:%s]', + consumerId, spatialLayer, temporalLayer); + + try + { + await this._protoo.request( + 'setConsumerPreferredLayers', { consumerId, spatialLayer, temporalLayer }); + + store.dispatch(stateActions.setConsumerPreferredLayers( + consumerId, spatialLayer, temporalLayer)); + } + catch (error) + { + logger.error('setConsumerPreferredLayers() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error setting Consumer preferred layers: ${error}` + })); + } + } + + async setConsumerPriority(consumerId, priority) + { + logger.debug( + 'setConsumerPriority() [consumerId:%s, priority:%d]', + consumerId, priority); + + try + { + await this._protoo.request('setConsumerPriority', { consumerId, priority }); + + store.dispatch(stateActions.setConsumerPriority(consumerId, priority)); + } + catch (error) + { + logger.error('setConsumerPriority() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error setting Consumer priority: ${error}` + })); + } + } + + async requestConsumerKeyFrame(consumerId) + { + logger.debug('requestConsumerKeyFrame() [consumerId:%s]', consumerId); + + try + { + await this._protoo.request('requestConsumerKeyFrame', { consumerId }); + + store.dispatch(requestActions.notify( + { + text : 'Keyframe requested for video consumer' + })); + } + catch (error) + { + logger.error('requestConsumerKeyFrame() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error requesting key frame for Consumer: ${error}` + })); + } + } + + async enableChatDataProducer() + { + logger.debug('enableChatDataProducer()'); + + if (!this._useDataChannel) + return; + + // NOTE: Should enable this code but it's useful for testing. + // if (this._chatDataProducer) + // return; + + try + { + // Create chat DataProducer. + this._chatDataProducer = await this._sendTransport.produceData( + { + ordered : false, + maxRetransmits : 1, + label : 'chat', + priority : 'medium', + appData : { info: 'my-chat-DataProducer' } + }); + + store.dispatch(stateActions.addDataProducer( + { + id : this._chatDataProducer.id, + sctpStreamParameters : this._chatDataProducer.sctpStreamParameters, + label : this._chatDataProducer.label, + protocol : this._chatDataProducer.protocol + })); + + this._chatDataProducer.on('transportclose', () => + { + this._chatDataProducer = null; + }); + + this._chatDataProducer.on('open', () => + { + logger.debug('chat DataProducer "open" event'); + }); + + this._chatDataProducer.on('close', () => + { + logger.error('chat DataProducer "close" event'); + + this._chatDataProducer = null; + + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Chat DataProducer closed' + })); + }); + + this._chatDataProducer.on('error', (error) => + { + logger.error('chat DataProducer "error" event:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Chat DataProducer error: ${error}` + })); + }); + + this._chatDataProducer.on('bufferedamountlow', () => + { + logger.debug('chat DataProducer "bufferedamountlow" event'); + }); + } + catch (error) + { + logger.error('enableChatDataProducer() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error enabling chat DataProducer: ${error}` + })); + + throw error; + } + } + + async enableBotDataProducer() + { + logger.debug('enableBotDataProducer()'); + + if (!this._useDataChannel) + return; + + // NOTE: Should enable this code but it's useful for testing. + // if (this._botDataProducer) + // return; + + try + { + // Create chat DataProducer. + this._botDataProducer = await this._sendTransport.produceData( + { + ordered : false, + maxPacketLifeTime : 2000, + label : 'bot', + priority : 'medium', + appData : { info: 'my-bot-DataProducer' } + }); + + store.dispatch(stateActions.addDataProducer( + { + id : this._botDataProducer.id, + sctpStreamParameters : this._botDataProducer.sctpStreamParameters, + label : this._botDataProducer.label, + protocol : this._botDataProducer.protocol + })); + + this._botDataProducer.on('transportclose', () => + { + this._botDataProducer = null; + }); + + this._botDataProducer.on('open', () => + { + logger.debug('bot DataProducer "open" event'); + }); + + this._botDataProducer.on('close', () => + { + logger.error('bot DataProducer "close" event'); + + this._botDataProducer = null; + + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Bot DataProducer closed' + })); + }); + + this._botDataProducer.on('error', (error) => + { + logger.error('bot DataProducer "error" event:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Bot DataProducer error: ${error}` + })); + }); + + this._botDataProducer.on('bufferedamountlow', () => + { + logger.debug('bot DataProducer "bufferedamountlow" event'); + }); + } + catch (error) + { + logger.error('enableBotDataProducer() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error enabling bot DataProducer: ${error}` + })); + + throw error; + } + } + + async sendChatMessage(text) + { + logger.debug('sendChatMessage() [text:"%s]', text); + + if (!this._chatDataProducer) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'No chat DataProducer' + })); + + return; + } + + try + { + this._chatDataProducer.send(text); + } + catch (error) + { + logger.error('chat DataProducer.send() failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `chat DataProducer.send() failed: ${error}` + })); + } + } + + async sendBotMessage(text) + { + logger.debug('sendBotMessage() [text:"%s]', text); + + if (!this._botDataProducer) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'No bot DataProducer' + })); + + return; + } + + try + { + this._botDataProducer.send(text); + } + catch (error) + { + logger.error('bot DataProducer.send() failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `bot DataProducer.send() failed: ${error}` + })); + } + } + + async changeDisplayName(displayName) + { + logger.debug('changeDisplayName() [displayName:"%s"]', displayName); + + // Store in cookie. + cookiesManager.setUser({ displayName }); + + try + { + await this._protoo.request('changeDisplayName', { displayName }); + + this._displayName = displayName; + + store.dispatch( + stateActions.setDisplayName(displayName)); + + store.dispatch(requestActions.notify( + { + text : 'Display name changed' + })); + } + catch (error) + { + logger.error('changeDisplayName() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Could not change display name: ${error}` + })); + + // We need to refresh the component for it to render the previous + // displayName again. + store.dispatch( + stateActions.setDisplayName()); + } + } + + async getSendTransportRemoteStats() + { + logger.debug('getSendTransportRemoteStats()'); + + if (!this._sendTransport) + return; + + return this._protoo.request( + 'getTransportStats', { transportId: this._sendTransport.id }); + } + + async getRecvTransportRemoteStats() + { + logger.debug('getRecvTransportRemoteStats()'); + + if (!this._recvTransport) + return; + + return this._protoo.request( + 'getTransportStats', { transportId: this._recvTransport.id }); + } + + async getAudioRemoteStats() + { + logger.debug('getAudioRemoteStats()'); + + if (!this._micProducer) + return; + + return this._protoo.request( + 'getProducerStats', { producerId: this._micProducer.id }); + } + + async getVideoRemoteStats() + { + logger.debug('getVideoRemoteStats()'); + + const producer = this._webcamProducer || this._shareProducer; + + if (!producer) + return; + + return this._protoo.request( + 'getProducerStats', { producerId: producer.id }); + } + + async getConsumerRemoteStats(consumerId) + { + logger.debug('getConsumerRemoteStats()'); + + const consumer = this._consumers.get(consumerId); + + if (!consumer) + return; + + return this._protoo.request('getConsumerStats', { consumerId }); + } + + async getChatDataProducerRemoteStats() + { + logger.debug('getChatDataProducerRemoteStats()'); + + const dataProducer = this._chatDataProducer; + + if (!dataProducer) + return; + + return this._protoo.request( + 'getDataProducerStats', { dataProducerId: dataProducer.id }); + } + + async getBotDataProducerRemoteStats() + { + logger.debug('getBotDataProducerRemoteStats()'); + + const dataProducer = this._botDataProducer; + + if (!dataProducer) + return; + + return this._protoo.request( + 'getDataProducerStats', { dataProducerId: dataProducer.id }); + } + + async getDataConsumerRemoteStats(dataConsumerId) + { + logger.debug('getDataConsumerRemoteStats()'); + + const dataConsumer = this._dataConsumers.get(dataConsumerId); + + if (!dataConsumer) + return; + + return this._protoo.request('getDataConsumerStats', { dataConsumerId }); + } + + async getSendTransportLocalStats() + { + logger.debug('getSendTransportLocalStats()'); + + if (!this._sendTransport) + return; + + return this._sendTransport.getStats(); + } + + async getRecvTransportLocalStats() + { + logger.debug('getRecvTransportLocalStats()'); + + if (!this._recvTransport) + return; + + return this._recvTransport.getStats(); + } + + async getAudioLocalStats() + { + logger.debug('getAudioLocalStats()'); + + if (!this._micProducer) + return; + + return this._micProducer.getStats(); + } + + async getVideoLocalStats() + { + logger.debug('getVideoLocalStats()'); + + const producer = this._webcamProducer || this._shareProducer; + + if (!producer) + return; + + return producer.getStats(); + } + + async getConsumerLocalStats(consumerId) + { + const consumer = this._consumers.get(consumerId); + + if (!consumer) + return; + + return consumer.getStats(); + } + + async applyNetworkThrottle({ uplink, downlink, rtt, secret, packetLoss }) + { + logger.debug( + 'applyNetworkThrottle() [uplink:%s, downlink:%s, rtt:%s, packetLoss:%s]', + uplink, downlink, rtt, packetLoss); + + try + { + await this._protoo.request( + 'applyNetworkThrottle', + { secret, uplink, downlink, rtt, packetLoss }); + } + catch (error) + { + logger.error('applyNetworkThrottle() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error applying network throttle: ${error}` + })); + } + } + + async resetNetworkThrottle({ silent = false, secret }) + { + logger.debug('resetNetworkThrottle()'); + + try + { + await this._protoo.request('resetNetworkThrottle', { secret }); + } + catch (error) + { + if (!silent) + { + logger.error('resetNetworkThrottle() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error resetting network throttle: ${error}` + })); + } + } + } + + async _joinRoom() + { + logger.debug('_joinRoom()'); + + try + { + this._mediasoupDevice = new mediasoupClient.Device( + { + handlerName : this._handlerName + }); + + store.dispatch(stateActions.setRoomMediasoupClientHandler( + this._mediasoupDevice.handlerName + )); + + const routerRtpCapabilities = + await this._protoo.request('getRouterRtpCapabilities'); + + await this._mediasoupDevice.load({ routerRtpCapabilities }); + + // NOTE: Stuff to play remote audios due to browsers' new autoplay policy. + // + // Just get access to the mic and DO NOT close the mic track for a while. + // Super hack! + { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioTrack = stream.getAudioTracks()[0]; + + audioTrack.enabled = false; + + setTimeout(() => audioTrack.stop(), 120000); + } + // Create mediasoup Transport for sending (unless we don't want to produce). + if (this._produce) + { + const transportInfo = await this._protoo.request( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : true, + consuming : false, + sctpCapabilities : this._useDataChannel + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + const { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters + } = transportInfo; + + this._sendTransport = this._mediasoupDevice.createSendTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters : + { + ...dtlsParameters, + // Remote DTLS role. We know it's always 'auto' by default so, if + // we want, we can force local WebRTC transport to be 'client' by + // indicating 'server' here and vice-versa. + role : 'auto' + }, + sctpParameters, + iceServers : [], + proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS, + additionalSettings : + { encodedInsertableStreams: this._e2eKey && e2e.isSupported() } + }); + + this._sendTransport.on( + 'connect', ({ iceParameters, dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this._protoo.request( + 'connectWebRtcTransport', + { + transportId : this._sendTransport.id, + iceParameters, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + + this._sendTransport.on( + 'produce', async ({ kind, rtpParameters, appData }, callback, errback) => + { + try + { + // eslint-disable-next-line no-shadow + const { id } = await this._protoo.request( + 'produce', + { + transportId : this._sendTransport.id, + kind, + rtpParameters, + appData + }); + + callback({ id }); + } + catch (error) + { + errback(error); + } + }); + + this._sendTransport.on('producedata', async ( + { + sctpStreamParameters, + label, + protocol, + appData + }, + callback, + errback + ) => + { + logger.debug( + '"producedata" event: [sctpStreamParameters:%o, appData:%o]', + sctpStreamParameters, appData); + + try + { + // eslint-disable-next-line no-shadow + const { id } = await this._protoo.request( + 'produceData', + { + transportId : this._sendTransport.id, + sctpStreamParameters, + label, + protocol, + appData + }); + + callback({ id }); + } + catch (error) + { + errback(error); + } + }); + } + + // Create mediasoup Transport for receiving (unless we don't want to consume). + if (this._consume) + { + const transportInfo = await this._protoo.request( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : false, + consuming : true, + sctpCapabilities : this._useDataChannel + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + const { + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters + } = transportInfo; + + this._recvTransport = this._mediasoupDevice.createRecvTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters : + { + ...dtlsParameters, + // Remote DTLS role. We know it's always 'auto' by default so, if + // we want, we can force local WebRTC transport to be 'client' by + // indicating 'server' here and vice-versa. + role : 'auto' + }, + sctpParameters, + iceServers : [], + additionalSettings : + { encodedInsertableStreams: this._e2eKey && e2e.isSupported() } + }); + + this._recvTransport.on( + 'connect', ({ iceParameters, dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this._protoo.request( + 'connectWebRtcTransport', + { + transportId : this._recvTransport.id, + iceParameters, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + } + + // Join now into the room. + // NOTE: Don't send our RTP capabilities if we don't want to consume. + const { peers } = await this._protoo.request( + 'join', + { + displayName : this._displayName, + device : this._device, + rtpCapabilities : this._consume + ? this._mediasoupDevice.rtpCapabilities + : undefined, + sctpCapabilities : this._useDataChannel && this._consume + ? this._mediasoupDevice.sctpCapabilities + : undefined + }); + + store.dispatch( + stateActions.setRoomState('connected')); + + // Clean all the existing notifcations. + store.dispatch( + stateActions.removeAllNotifications()); + + store.dispatch(requestActions.notify( + { + text : 'You are in the room!', + timeout : 3000 + })); + + for (const peer of peers) + { + store.dispatch( + stateActions.addPeer( + { ...peer, consumers: [], dataConsumers: [] })); + } + + // Enable mic/webcam. + if (this._produce) + { + // Set our media capabilities. + store.dispatch(stateActions.setMediaCapabilities( + { + canSendMic : this._mediasoupDevice.canProduce('audio'), + canSendWebcam : this._mediasoupDevice.canProduce('video') + })); + + this.enableMic(); + + const devicesCookie = cookiesManager.getDevices(); + + if (!devicesCookie || devicesCookie.webcamEnabled || this._externalVideo) + this.enableWebcam(); + + this._sendTransport.on('connectionstatechange', (connectionState) => + { + if (connectionState === 'connected') + { + this.enableChatDataProducer(); + this.enableBotDataProducer(); + } + }); + } + + // NOTE: For testing. + if (window.SHOW_INFO) + { + const { me } = store.getState(); + + store.dispatch( + stateActions.setRoomStatsPeerId(me.id)); + } + } + catch (error) + { + logger.error('_joinRoom() failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Could not join the room: ${error}` + })); + + this.close(); + } + } + + async _updateWebcams() + { + logger.debug('_updateWebcams()'); + + // Reset the list. + this._webcams = new Map(); + + logger.debug('_updateWebcams() | calling enumerateDevices()'); + + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) + { + if (device.kind !== 'videoinput') + continue; + + this._webcams.set(device.deviceId, device); + } + + const array = Array.from(this._webcams.values()); + const len = array.length; + const currentWebcamId = + this._webcam.device ? this._webcam.device.deviceId : undefined; + + logger.debug('_updateWebcams() [webcams:%o]', array); + + if (len === 0) + this._webcam.device = null; + else if (!this._webcams.has(currentWebcamId)) + this._webcam.device = array[0]; + + store.dispatch( + stateActions.setCanChangeWebcam(this._webcams.size > 1)); + } + + _getWebcamType(device) + { + if (/(back|rear)/i.test(device.label)) + { + logger.debug('_getWebcamType() | it seems to be a back camera'); + + return 'back'; + } + else + { + logger.debug('_getWebcamType() | it seems to be a front camera'); + + return 'front'; + } + } + + async _pauseConsumer(consumer) + { + if (consumer.paused) + return; + + try + { + await this._protoo.request('pauseConsumer', { consumerId: consumer.id }); + + consumer.pause(); + + store.dispatch( + stateActions.setConsumerPaused(consumer.id, 'local')); + } + catch (error) + { + logger.error('_pauseConsumer() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error pausing Consumer: ${error}` + })); + } + } + + async _resumeConsumer(consumer) + { + if (!consumer.paused) + return; + + try + { + await this._protoo.request('resumeConsumer', { consumerId: consumer.id }); + + consumer.resume(); + + store.dispatch( + stateActions.setConsumerResumed(consumer.id, 'local')); + } + catch (error) + { + logger.error('_resumeConsumer() | failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error resuming Consumer: ${error}` + })); + } + } + + async _getExternalVideoStream() + { + if (this._externalVideoStream) + return this._externalVideoStream; + + if (this._externalVideo.readyState < 3) + { + await new Promise((resolve) => ( + this._externalVideo.addEventListener('canplay', resolve) + )); + } + + if (this._externalVideo.captureStream) + this._externalVideoStream = this._externalVideo.captureStream(); + else if (this._externalVideo.mozCaptureStream) + this._externalVideoStream = this._externalVideo.mozCaptureStream(); + else + throw new Error('video.captureStream() not supported'); + + return this._externalVideoStream; + } +} diff --git a/app/lib/RoomContext.js b/app/lib/RoomContext.js new file mode 100644 index 0000000..d4b79ad --- /dev/null +++ b/app/lib/RoomContext.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const RoomContext = React.createContext(); + +export default RoomContext; + +export function withRoomContext(Component) +{ + return (props) => ( // eslint-disable-line react/display-name + + {(roomClient) => } + + ); +} diff --git a/app/lib/components/ChatInput.jsx b/app/lib/components/ChatInput.jsx new file mode 100644 index 0000000..626d6e4 --- /dev/null +++ b/app/lib/components/ChatInput.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../RoomContext'; + +const BotMessageRegex = new RegExp('^@bot (.*)'); + +class ChatInput extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + text : '' + }; + + // TextArea element got via React ref. + // @type {HTMLElement} + this._textareaElem = null; + } + + render() + { + const { + connected, + chatDataProducer, + botDataProducer + } = this.props; + + const { text } = this.state; + + const disabled = !connected || (!chatDataProducer && !botDataProducer); + + return ( +
+