From 52acf81effd336b94abfebdcd3f8fd68a704f8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki=20Baz=20Castillo?= Date: Thu, 2 Nov 2017 16:38:52 +0100 Subject: [PATCH] v2 --- .gitignore | 8 +- README.md | 8 +- app/.eslintrc.js | 283 +- app/config.example.js | 7 - app/gulpfile.js | 92 +- app/index.html | 10 +- app/lib/Client.js | 934 - app/lib/Logger.js | 14 +- app/lib/RoomClient.js | 1148 ++ app/lib/components/App.jsx | 57 - app/lib/components/EditableInput.jsx | 51 + app/lib/components/LocalVideo.jsx | 156 - app/lib/components/Me.jsx | 222 + app/lib/components/Notifications.jsx | 61 + app/lib/components/Notifier.jsx | 151 - app/lib/components/Peer.jsx | 90 + app/lib/components/PeerView.jsx | 260 + app/lib/components/Peers.jsx | 53 + app/lib/components/RemoteVideo.jsx | 138 - app/lib/components/Room.jsx | 648 +- app/lib/components/Stats.jsx | 585 - app/lib/components/TransitionAppear.jsx | 60 - app/lib/components/Video.jsx | 241 - app/lib/components/appPropTypes.js | 71 + app/lib/components/muiTheme.js | 16 - app/lib/components/transitions.jsx | 22 + app/lib/cookiesManager.js | 24 + app/lib/edge/RTCPeerConnection.js | 2502 --- app/lib/edge/RTCSessionDescription.js | 105 - app/lib/edge/errors.js | 21 - app/lib/edge/ortcUtils.js | 458 - app/lib/index.jsx | 191 +- app/lib/redux/STATE.md | 99 + app/lib/redux/reducers/consumers.js | 75 + app/lib/redux/reducers/index.js | 19 + app/lib/redux/reducers/me.js | 85 + app/lib/redux/reducers/notifications.js | 31 + app/lib/redux/reducers/peers.js | 79 + app/lib/redux/reducers/producers.js | 66 + app/lib/redux/reducers/room.js | 41 + app/lib/redux/requestActions.js | 117 + app/lib/redux/roomClientMiddleware.js | 115 + app/lib/redux/stateActions.js | 222 + app/lib/urlFactory.js | 11 +- app/lib/utils.js | 63 +- app/package-lock.json | 5070 ++++-- app/package.json | 58 +- app/resources/images/body-bg-2.jpg | Bin 468952 -> 214404 bytes app/resources/images/body-bg.jpg | Bin 104446 -> 468952 bytes app/resources/images/devices/chrome_16x16.png | Bin 0 -> 861 bytes app/resources/images/devices/edge_16x16.png | Bin 0 -> 365 bytes .../images/devices/firefox_16x16.png | Bin 0 -> 946 bytes app/resources/images/devices/opera_16x16.png | Bin 0 -> 632 bytes app/resources/images/devices/safari_16x16.png | Bin 0 -> 917 bytes app/resources/images/devices/sip_endpoint.svg | 4 + app/resources/images/devices/unknown.svg | 4 + .../images/icon_audio_only_black.svg | 4 + .../images/icon_audio_only_white.svg | 4 + .../images/icon_change_webcam_black.svg | 4 + .../icon_change_webcam_white_unsupported.svg | 4 + app/resources/images/icon_mic_black_on.svg | 4 + app/resources/images/icon_mic_white_off.svg | 4 + .../images/icon_mic_white_unsupported.svg | 4 + .../images/icon_notification_error_white.svg | 4 + .../images/icon_notification_info_white.svg | 4 + .../images/icon_remote_mic_white_off.svg | 4 + .../images/icon_remote_webcam_white_off.svg | 4 + .../images/icon_restart_ice_white.svg | 4 + app/resources/images/icon_webcam_black_on.svg | 4 + app/resources/images/icon_webcam_white_on.svg | 4 + .../images/icon_webcam_white_unsupported.svg | 4 + ...paint-stains-spots-bright-5K-wallpaper.jpg | Bin 0 -> 2299745 bytes app/stylus/components/App.styl | 5 - app/stylus/components/LocalVideo.styl | 100 - app/stylus/components/Me.styl | 98 + app/stylus/components/Notifications.styl | 101 + app/stylus/components/Peer.styl | 72 + app/stylus/components/PeerView.styl | 262 + app/stylus/components/Peers.styl | 60 + app/stylus/components/RemoteVideo.styl | 114 - app/stylus/components/Room.styl | 220 +- app/stylus/components/Stats.styl | 114 - app/stylus/components/Video.styl | 84 - app/stylus/index.styl | 28 +- app/stylus/mixins.styl | 8 +- app/stylus/reset.styl | 14 + app/test/DATA.js | 401 + app/test/gulpfile.js | 145 + app/test/index.html | 16 + app/test/index.jsx | 692 + app/test/output/index.html | 16 + app/test/output/mediasoup-client-test.js | 14243 ++++++++++++++++ server/.eslintrc.js | 168 + server/config.example.js | 52 +- server/gulpfile.js | 53 +- server/lib/Room.js | 816 +- server/lib/logger.js | 31 +- server/package-lock.json | 4054 +++-- server/package.json | 14 +- server/server.js | 254 +- 100 files changed, 26907 insertions(+), 10234 deletions(-) delete mode 100644 app/config.example.js delete mode 100644 app/lib/Client.js create mode 100644 app/lib/RoomClient.js delete mode 100644 app/lib/components/App.jsx create mode 100644 app/lib/components/EditableInput.jsx delete mode 100644 app/lib/components/LocalVideo.jsx create mode 100644 app/lib/components/Me.jsx create mode 100644 app/lib/components/Notifications.jsx delete mode 100644 app/lib/components/Notifier.jsx create mode 100644 app/lib/components/Peer.jsx create mode 100644 app/lib/components/PeerView.jsx create mode 100644 app/lib/components/Peers.jsx delete mode 100644 app/lib/components/RemoteVideo.jsx delete mode 100644 app/lib/components/Stats.jsx delete mode 100644 app/lib/components/TransitionAppear.jsx delete mode 100644 app/lib/components/Video.jsx create mode 100644 app/lib/components/appPropTypes.js delete mode 100644 app/lib/components/muiTheme.js create mode 100644 app/lib/components/transitions.jsx create mode 100644 app/lib/cookiesManager.js delete mode 100644 app/lib/edge/RTCPeerConnection.js delete mode 100644 app/lib/edge/RTCSessionDescription.js delete mode 100644 app/lib/edge/errors.js delete mode 100644 app/lib/edge/ortcUtils.js create mode 100644 app/lib/redux/STATE.md create mode 100644 app/lib/redux/reducers/consumers.js create mode 100644 app/lib/redux/reducers/index.js create mode 100644 app/lib/redux/reducers/me.js create mode 100644 app/lib/redux/reducers/notifications.js create mode 100644 app/lib/redux/reducers/peers.js create mode 100644 app/lib/redux/reducers/producers.js create mode 100644 app/lib/redux/reducers/room.js create mode 100644 app/lib/redux/requestActions.js create mode 100644 app/lib/redux/roomClientMiddleware.js create mode 100644 app/lib/redux/stateActions.js create mode 100644 app/resources/images/devices/chrome_16x16.png create mode 100644 app/resources/images/devices/edge_16x16.png create mode 100644 app/resources/images/devices/firefox_16x16.png create mode 100644 app/resources/images/devices/opera_16x16.png create mode 100644 app/resources/images/devices/safari_16x16.png create mode 100644 app/resources/images/devices/sip_endpoint.svg create mode 100644 app/resources/images/devices/unknown.svg create mode 100644 app/resources/images/icon_audio_only_black.svg create mode 100644 app/resources/images/icon_audio_only_white.svg create mode 100644 app/resources/images/icon_change_webcam_black.svg create mode 100644 app/resources/images/icon_change_webcam_white_unsupported.svg create mode 100644 app/resources/images/icon_mic_black_on.svg create mode 100644 app/resources/images/icon_mic_white_off.svg create mode 100644 app/resources/images/icon_mic_white_unsupported.svg create mode 100644 app/resources/images/icon_notification_error_white.svg create mode 100644 app/resources/images/icon_notification_info_white.svg create mode 100644 app/resources/images/icon_remote_mic_white_off.svg create mode 100644 app/resources/images/icon_remote_webcam_white_off.svg create mode 100644 app/resources/images/icon_restart_ice_white.svg create mode 100644 app/resources/images/icon_webcam_black_on.svg create mode 100644 app/resources/images/icon_webcam_white_on.svg create mode 100644 app/resources/images/icon_webcam_white_unsupported.svg create mode 100644 app/resources/images/paint-stains-spots-bright-5K-wallpaper.jpg delete mode 100644 app/stylus/components/App.styl delete mode 100644 app/stylus/components/LocalVideo.styl create mode 100644 app/stylus/components/Me.styl create mode 100644 app/stylus/components/Notifications.styl create mode 100644 app/stylus/components/Peer.styl create mode 100644 app/stylus/components/PeerView.styl create mode 100644 app/stylus/components/Peers.styl delete mode 100644 app/stylus/components/RemoteVideo.styl delete mode 100644 app/stylus/components/Stats.styl delete mode 100644 app/stylus/components/Video.styl create mode 100644 app/stylus/reset.styl create mode 100644 app/test/DATA.js create mode 100644 app/test/gulpfile.js create mode 100644 app/test/index.html create mode 100644 app/test/index.jsx create mode 100644 app/test/output/index.html create mode 100644 app/test/output/mediasoup-client-test.js create mode 100644 server/.eslintrc.js diff --git a/.gitignore b/.gitignore index 5396725..30d8e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ node_modules/ -/app/config.* -!/app/config.example.js - /server/config.* !/server/config.example.js /server/public/ -/server/certs/* -!/server/certs/mediasoup-demo.localhost.cert.pem -!/server/certs/mediasoup-demo.localhost.key.pem +/server/certs/ +!/server/certs/mediasoup-demo.localhost.* diff --git a/README.md b/README.md index c1cce77..c437d73 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,6 @@ $ cd app $ npm install ``` -* Copy `config.example.js` as `config.js`: - -```bash -$ cp config.example.js config.js -``` - * Globally install `gulp-cli` NPM module (may need `sudo`): ```bash @@ -77,7 +71,7 @@ $ gulp prod * Upload the entire `server` folder to your server and make your web server (Apache, Nginx...) expose the `server/public` folder. -* Edit your `server/config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, etc). Also set the proper remote WebSocket port in `client/config.js`. +* Edit your `server/config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, etc). * Within your server, run the server side Node.js application. We recommend using the [forever](https://www.npmjs.com/package/forever) NPM daemon launcher, but any other can be used: diff --git a/app/.eslintrc.js b/app/.eslintrc.js index 88e6e51..70a5569 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -1,96 +1,229 @@ module.exports = { - env : + env: { - 'browser' : true, - 'es6' : true, - 'node' : true, - 'commonjs' : true + browser: true, + es6: true, + node: true }, - plugins : + plugins: [ - 'react', - 'import' + 'import', + 'react' ], - extends : + extends: [ 'eslint:recommended', 'plugin:react/recommended' ], - settings : + settings: { - react : + react: { - pragma : 'React', - version : '15' + pragma: 'React', + version: '15' } }, - parserOptions : + parserOptions: { - ecmaVersion : 6, - sourceType : 'module', - ecmaFeatures : + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: { - impliedStrict : true, - jsx : true + impliedStrict: true, + experimentalObjectRestSpread: true, + jsx: true } }, - rules : + rules: { - 'no-console' : 0, - 'no-undef' : 2, - 'no-unused-vars' : [ 1, { vars: 'all', args: 'after-used' }], - 'no-empty' : 0, - 'quotes' : [ 2, 'single', { avoidEscape: true } ], - 'semi' : [ 2, 'always' ], - 'no-multi-spaces' : 0, - 'no-whitespace-before-property' : 2, - 'space-before-blocks' : 2, - 'space-before-function-paren' : [ 2, 'never' ], - 'space-in-parens' : [ 2, 'never' ], - 'spaced-comment' : [ 2, 'always' ], - 'comma-spacing' : [ 2, { before: false, after: true } ], - 'jsx-quotes' : [ 2, 'prefer-single' ], - 'react/display-name' : [ 2, { ignoreTranspilerName: false } ], - 'react/forbid-prop-types' : 0, - 'react/jsx-boolean-value' : 1, - 'react/jsx-closing-bracket-location' : 1, - 'react/jsx-curly-spacing' : 1, - 'react/jsx-equals-spacing' : 1, - 'react/jsx-handler-names' : 1, - 'react/jsx-indent-props' : [ 2, 'tab' ], - 'react/jsx-indent' : [ 2, 'tab' ], - 'react/jsx-key' : 1, - 'react/jsx-max-props-per-line' : 0, - 'react/jsx-no-bind' : 0, - 'react/jsx-no-duplicate-props' : 1, - 'react/jsx-no-literals' : 0, - 'react/jsx-no-undef' : 1, - 'react/jsx-pascal-case' : 1, - 'react/jsx-sort-prop-types' : 0, - 'react/jsx-sort-props' : 0, - 'react/jsx-uses-react' : 1, - 'react/jsx-uses-vars' : 1, - 'react/no-danger' : 1, - 'react/no-deprecated' : 1, - 'react/no-did-mount-set-state' : 1, - 'react/no-did-update-set-state' : 1, - 'react/no-direct-mutation-state' : 1, - 'react/no-is-mounted' : 1, - 'react/no-multi-comp' : 0, - 'react/no-set-state' : 0, - 'react/no-string-refs' : 0, - 'react/no-unknown-property' : 1, - 'react/prefer-es6-class' : 1, - 'react/prop-types' : 1, - 'react/react-in-jsx-scope' : 1, - 'react/self-closing-comp' : 1, - 'react/sort-comp' : 0, - 'react/jsx-wrap-multilines' : - [ - 1, - { declaration: false, assignment: false, return: true } - ], - 'import/extensions' : 1 + '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: true, + beforeLineComment: false + }], + 'max-len': [ 2, 90, + { + 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, 'never' ], + 'space-in-parens': [ 2, 'never' ], + 'spaced-comment': [ 2, 'always' ], + 'strict': 2, + 'valid-typeof': 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': 2, + '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, + '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/config.example.js b/app/config.example.js deleted file mode 100644 index 87bf913..0000000 --- a/app/config.example.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = -{ - protoo : - { - listenPort : 3443 - } -}; diff --git a/app/gulpfile.js b/app/gulpfile.js index 9e3b147..6f06c06 100644 --- a/app/gulpfile.js +++ b/app/gulpfile.js @@ -1,17 +1,13 @@ -'use strict'; - /** * Tasks: * - * gulp prod - * Generates the browser app in production mode. - * - * gulp dev - * Generates the browser app in development mode. + * 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, opens it and watches - * for changes in the source code. + * 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 * Alias for `gulp live`. @@ -23,9 +19,9 @@ const gulp = require('gulp'); const gulpif = require('gulp-if'); const gutil = require('gulp-util'); const plumber = require('gulp-plumber'); -const touch = require('gulp-touch'); 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'); @@ -50,24 +46,25 @@ const BANNER_OPTIONS = }; const OUTPUT_DIR = '../server/public'; -// Default environment. -process.env.NODE_ENV = 'development'; +// 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(String(error))); - - throw error; + gutil.log(gutil.colors.red(error.stack)); } function bundle(options) { options = options || {}; - let watch = !!options.watch; + const watch = Boolean(options.watch); + let bundler = browserify( { - entries : path.join(__dirname, PKG.main), + entries : PKG.main, extensions : [ '.js', '.jsx' ], // required for sourcemaps (must be false otherwise). debug : process.env.NODE_ENV === 'development', @@ -81,7 +78,12 @@ function bundle(options) .transform('babelify', { presets : [ 'es2015', 'react' ], - plugins : [ 'transform-runtime', 'transform-object-assign' ] + plugins : + [ + 'transform-runtime', + 'transform-object-assign', + 'transform-object-rest-spread' + ] }) .transform(envify( { @@ -95,7 +97,7 @@ function bundle(options) bundler.on('update', () => { - let start = Date.now(); + const start = Date.now(); gutil.log('bundling...'); rebundle(); @@ -107,6 +109,7 @@ function bundle(options) { return bundler.bundle() .on('error', logError) + .pipe(plumber()) .pipe(source(`${PKG.name}.js`)) .pipe(buffer()) .pipe(rename(`${PKG.name}.js`)) @@ -122,25 +125,14 @@ function bundle(options) gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); -gulp.task('env:dev', (done) => -{ - gutil.log('setting "dev" environment'); - - process.env.NODE_ENV = 'development'; - done(); -}); - -gulp.task('env:prod', (done) => -{ - gutil.log('setting "prod" environment'); - - process.env.NODE_ENV = 'production'; - done(); -}); - gulp.task('lint', () => { - let src = [ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ]; + const src = + [ + 'gulpfile.js', + 'lib/**/*.js', + 'lib/**/*.jsx' + ]; return gulp.src(src) .pipe(plumber()) @@ -176,7 +168,7 @@ gulp.task('html', () => gulp.task('resources', (done) => { - let dst = path.join(OUTPUT_DIR, 'resources'); + const dst = path.join(OUTPUT_DIR, 'resources'); mkdirp.sync(dst); ncp('resources', dst, { stopOnErr: true }, (error) => @@ -204,9 +196,9 @@ gulp.task('livebrowser', (done) => browserSync( { - open : 'external', - host : config.domain, - server : + open : 'external', + host : config.domain, + server : { baseDir : OUTPUT_DIR }, @@ -224,9 +216,9 @@ gulp.task('browser', (done) => browserSync( { - open : 'external', - host : config.domain, - server : + open : 'external', + host : config.domain, + server : { baseDir : OUTPUT_DIR }, @@ -262,18 +254,7 @@ gulp.task('watch', (done) => done(); }); -gulp.task('prod', gulp.series( - 'env:prod', - 'clean', - 'lint', - 'bundle', - 'html', - 'css', - 'resources' -)); - -gulp.task('dev', gulp.series( - 'env:dev', +gulp.task('dist', gulp.series( 'clean', 'lint', 'bundle', @@ -283,7 +264,6 @@ gulp.task('dev', gulp.series( )); gulp.task('live', gulp.series( - 'env:dev', 'clean', 'lint', 'bundle:watch', diff --git a/app/index.html b/app/index.html index 85a16d6..17a3312 100644 --- a/app/index.html +++ b/app/index.html @@ -2,21 +2,21 @@ - mediasoup demo + mediasoup v2 demo - + diff --git a/app/lib/Client.js b/app/lib/Client.js deleted file mode 100644 index 0ba266a..0000000 --- a/app/lib/Client.js +++ /dev/null @@ -1,934 +0,0 @@ -'use strict'; - -import events from 'events'; -import browser from 'bowser'; -import sdpTransform from 'sdp-transform'; -import Logger from './Logger'; -import protooClient from 'protoo-client'; -import * as urlFactory from './urlFactory'; -import * as utils from './utils'; - -const logger = new Logger('Client'); - -const DO_GETUSERMEDIA = true; -const ENABLE_SIMULCAST = false; -const VIDEO_CONSTRAINS = -{ - qvga : { width: { ideal: 320 }, height: { ideal: 240 }}, - vga : { width: { ideal: 640 }, height: { ideal: 480 }}, - hd : { width: { ideal: 1280 }, height: { ideal: 720 }} -}; - -export default class Client extends events.EventEmitter -{ - constructor(peerId, roomId) - { - logger.debug('constructor() [peerId:"%s", roomId:"%s"]', peerId, roomId); - - super(); - this.setMaxListeners(Infinity); - - // TODO: TMP - global.CLIENT = this; - - let url = urlFactory.getProtooUrl(peerId, roomId); - let transport = new protooClient.WebSocketTransport(url); - - // protoo-client Peer instance. - this._protooPeer = new protooClient.Peer(transport); - - // RTCPeerConnection instance. - this._peerconnection = null; - - // Webcam map indexed by deviceId. - this._webcams = new Map(); - - // Local Webcam device. - this._webcam = null; - - // Local MediaStream instance. - this._localStream = null; - - // Closed flag. - this._closed = false; - - // Local video resolution. - this._localVideoResolution = 'vga'; - - this._protooPeer.on('open', () => - { - logger.debug('protoo Peer "open" event'); - }); - - this._protooPeer.on('disconnected', () => - { - logger.warn('protoo Peer "disconnected" event'); - - // Close RTCPeerConnection. - try - { - this._peerconnection.close(); - } - catch (error) {} - - // Close local MediaStream. - if (this._localStream) - utils.closeMediaStream(this._localStream); - - this.emit('disconnected'); - }); - - this._protooPeer.on('close', () => - { - if (this._closed) - return; - - logger.warn('protoo Peer "close" event'); - - this.close(); - }); - - this._protooPeer.on('request', this._handleRequest.bind(this)); - } - - close() - { - if (this._closed) - return; - - this._closed = true; - - logger.debug('close()'); - - // Close protoo Peer. - this._protooPeer.close(); - - // Close RTCPeerConnection. - try - { - this._peerconnection.close(); - } - catch (error) {} - - // Close local MediaStream. - if (this._localStream) - utils.closeMediaStream(this._localStream); - - // Emit 'close' event. - this.emit('close'); - } - - removeVideo(dontNegotiate) - { - logger.debug('removeVideo()'); - - let stream = this._localStream; - let videoTrack = stream.getVideoTracks()[0]; - - if (!videoTrack) - { - logger.warn('removeVideo() | no video track'); - - return Promise.reject(new Error('no video track')); - } - - videoTrack.stop(); - stream.removeTrack(videoTrack); - - // New API. - if (this._peerconnection.removeTrack) - { - let sender; - - for (sender of this._peerconnection.getSenders()) - { - if (sender.track === videoTrack) - break; - } - - this._peerconnection.removeTrack(sender); - } - // Old API. - else - { - this._peerconnection.addStream(stream); - } - - if (!dontNegotiate) - { - this.emit('localstream', stream, null); - - return this._requestRenegotiation(); - } - } - - addVideo() - { - logger.debug('addVideo()'); - - let stream = this._localStream; - let videoTrack; - let videoResolution = this._localVideoResolution; // Keep previous resolution. - - if (stream) - videoTrack = stream.getVideoTracks()[0]; - - if (videoTrack) - { - logger.warn('addVideo() | there is already a video track'); - - return Promise.reject(new Error('there is already a video track')); - } - - return this._getLocalStream( - { - video : VIDEO_CONSTRAINS[videoResolution] - }) - .then((newStream) => - { - let newVideoTrack = newStream.getVideoTracks()[0]; - - if (stream) - { - stream.addTrack(newVideoTrack); - - // New API. - if (this._peerconnection.addTrack) - { - this._peerconnection.addTrack(newVideoTrack, stream); - } - // Old API. - else - { - this._peerconnection.addStream(stream); - } - } - else - { - this._localStream = newStream; - - // New API. - if (this._peerconnection.addTrack) - { - this._peerconnection.addTrack(newVideoTrack, stream); - } - // Old API. - else - { - this._peerconnection.addStream(stream); - } - } - - this.emit('localstream', this._localStream, videoResolution); - }) - .then(() => - { - return this._requestRenegotiation(); - }) - .catch((error) => - { - logger.error('addVideo() failed: %o', error); - - throw error; - }); - } - - changeWebcam() - { - logger.debug('changeWebcam()'); - - return Promise.resolve() - .then(() => - { - return this._updateWebcams(); - }) - .then(() => - { - let array = Array.from(this._webcams.keys()); - let len = array.length; - let deviceId = this._webcam ? this._webcam.deviceId : undefined; - let idx = array.indexOf(deviceId); - - if (idx < len - 1) - idx++; - else - idx = 0; - - this._webcam = this._webcams.get(array[idx]); - - this._emitWebcamType(); - - if (len < 2) - return; - - logger.debug( - 'changeWebcam() | new selected webcam [deviceId:"%s"]', - this._webcam.deviceId); - - // Reset video resolution to VGA. - this._localVideoResolution = 'vga'; - - // For Chrome (old WenRTC API). - // Replace the track (so new SSRC) and renegotiate. - if (!this._peerconnection.removeTrack) - { - this.removeVideo(true); - - return this.addVideo(); - } - // For Firefox (modern WebRTC API). - // Avoid renegotiation. - else - { - return this._getLocalStream( - { - video : VIDEO_CONSTRAINS[this._localVideoResolution] - }) - .then((newStream) => - { - let newVideoTrack = newStream.getVideoTracks()[0]; - let stream = this._localStream; - let oldVideoTrack = stream.getVideoTracks()[0]; - let sender; - - for (sender of this._peerconnection.getSenders()) - { - if (sender.track === oldVideoTrack) - break; - } - - sender.replaceTrack(newVideoTrack); - stream.removeTrack(oldVideoTrack); - oldVideoTrack.stop(); - stream.addTrack(newVideoTrack); - - this.emit('localstream', stream, this._localVideoResolution); - }); - } - }) - .catch((error) => - { - logger.error('changeWebcam() failed: %o', error); - }); - } - - changeVideoResolution() - { - logger.debug('changeVideoResolution()'); - - let newVideoResolution; - - switch (this._localVideoResolution) - { - case 'qvga': - newVideoResolution = 'vga'; - break; - case 'vga': - newVideoResolution = 'hd'; - break; - case 'hd': - newVideoResolution = 'qvga'; - break; - default: - throw new Error(`unknown resolution "${this._localVideoResolution}"`); - } - - this._localVideoResolution = newVideoResolution; - - // For Chrome (old WenRTC API). - // Replace the track (so new SSRC) and renegotiate. - if (!this._peerconnection.removeTrack) - { - this.removeVideo(true); - - return this.addVideo(); - } - // For Firefox (modern WebRTC API). - // Avoid renegotiation. - else - { - return this._getLocalStream( - { - video : VIDEO_CONSTRAINS[this._localVideoResolution] - }) - .then((newStream) => - { - let newVideoTrack = newStream.getVideoTracks()[0]; - let stream = this._localStream; - let oldVideoTrack = stream.getVideoTracks()[0]; - let sender; - - for (sender of this._peerconnection.getSenders()) - { - if (sender.track === oldVideoTrack) - break; - } - - sender.replaceTrack(newVideoTrack); - stream.removeTrack(oldVideoTrack); - oldVideoTrack.stop(); - stream.addTrack(newVideoTrack); - - this.emit('localstream', stream, newVideoResolution); - }) - .catch((error) => - { - logger.error('changeVideoResolution() failed: %o', error); - }); - } - } - - getStats() - { - return this._peerconnection.getStats() - .catch((error) => - { - logger.error('pc.getStats() failed: %o', error); - - throw error; - }); - } - - disableRemoteVideo(msid) - { - return this._protooPeer.send('disableremotevideo', { msid, disable: true }) - .catch((error) => - { - logger.warn('disableRemoteVideo() failed: %o', error); - }); - } - - enableRemoteVideo(msid) - { - return this._protooPeer.send('disableremotevideo', { msid, disable: false }) - .catch((error) => - { - logger.warn('enableRemoteVideo() failed: %o', error); - }); - } - - _handleRequest(request, accept, reject) - { - logger.debug('_handleRequest() [method:%s, data:%o]', request.method, request.data); - - switch(request.method) - { - case 'joinme': - { - let videoResolution = this._localVideoResolution; - - Promise.resolve() - .then(() => - { - return this._updateWebcams(); - }) - .then(() => - { - if (DO_GETUSERMEDIA) - { - return this._getLocalStream( - { - audio : true, - video : VIDEO_CONSTRAINS[videoResolution] - }) - .then((stream) => - { - logger.debug('got local stream [resolution:%s]', videoResolution); - - // Close local MediaStream if any. - if (this._localStream) - utils.closeMediaStream(this._localStream); - - this._localStream = stream; - - // Emit 'localstream' event. - this.emit('localstream', stream, videoResolution); - }); - } - }) - .then(() => - { - return this._createPeerConnection(); - }) - .then(() => - { - return this._peerconnection.createOffer( - { - offerToReceiveAudio : 1, - offerToReceiveVideo : 1 - }); - }) - .then((offer) => - { - let capabilities = offer.sdp; - let parsedSdp = sdpTransform.parse(capabilities); - - logger.debug('capabilities [parsed:%O, sdp:%s]', parsedSdp, capabilities); - - // Accept the protoo request. - accept( - { - capabilities : capabilities, - usePlanB : utils.isPlanB() - }); - }) - .then(() => - { - logger.debug('"joinme" request accepted'); - - // Emit 'join' event. - this.emit('join'); - }) - .catch((error) => - { - logger.error('"joinme" request failed: %o', error); - - reject(500, error.message); - throw error; - }); - - break; - } - - case 'peers': - { - this.emit('peers', request.data.peers); - accept(); - - break; - } - - case 'addpeer': - { - this.emit('addpeer', request.data.peer); - accept(); - - break; - } - - case 'updatepeer': - { - this.emit('updatepeer', request.data.peer); - accept(); - - break; - } - - case 'removepeer': - { - this.emit('removepeer', request.data.peer); - accept(); - - break; - } - - case 'offer': - { - let offer = new RTCSessionDescription(request.data.offer); - let parsedSdp = sdpTransform.parse(offer.sdp); - - logger.debug('received offer [parsed:%O, sdp:%s]', parsedSdp, offer.sdp); - - Promise.resolve() - .then(() => - { - return this._peerconnection.setRemoteDescription(offer); - }) - .then(() => - { - return this._peerconnection.createAnswer(); - }) - // Play with simulcast. - .then((answer) => - { - if (!ENABLE_SIMULCAST) - return answer; - - // Chrome Plan B simulcast. - if (utils.isPlanB()) - { - // Just for the initial offer. - // NOTE: Otherwise Chrome crashes. - // TODO: This prevents simulcast to be applied to new tracks. - if (this._peerconnection.localDescription && this._peerconnection.localDescription.sdp) - return answer; - - // TODO: Should be done just for VP8. - let parsedSdp = sdpTransform.parse(answer.sdp); - let videoMedia; - - for (let m of parsedSdp.media) - { - if (m.type === 'video') - { - videoMedia = m; - break; - } - } - - if (!videoMedia || !videoMedia.ssrcs) - return answer; - - logger.debug('setting video simulcast (PlanB)'); - - let ssrc1; - let ssrc2; - let ssrc3; - let cname; - let msid; - - for (let ssrcObj of videoMedia.ssrcs) - { - // Chrome uses: - // a=ssrc:xxxx msid:yyyy zzzz - // a=ssrc:xxxx mslabel:yyyy - // a=ssrc:xxxx label:zzzz - // Where yyyy is the MediaStream.id and zzzz the MediaStreamTrack.id. - switch (ssrcObj.attribute) - { - case 'cname': - ssrc1 = ssrcObj.id; - cname = ssrcObj.value; - break; - - case 'msid': - msid = ssrcObj.value; - break; - } - } - - ssrc2 = ssrc1 + 1; - ssrc3 = ssrc1 + 2; - - videoMedia.ssrcGroups = - [ - { - semantics : 'SIM', - ssrcs : `${ssrc1} ${ssrc2} ${ssrc3}` - } - ]; - - videoMedia.ssrcs = - [ - { - id : ssrc1, - attribute : 'cname', - value : cname, - }, - { - id : ssrc1, - attribute : 'msid', - value : msid, - }, - { - id : ssrc2, - attribute : 'cname', - value : cname, - }, - { - id : ssrc2, - attribute : 'msid', - value : msid, - }, - { - id : ssrc3, - attribute : 'cname', - value : cname, - }, - { - id : ssrc3, - attribute : 'msid', - value : msid, - } - ]; - - let modifiedAnswer = - { - type : 'answer', - sdp : sdpTransform.write(parsedSdp) - }; - - return modifiedAnswer; - } - // Firefox way. - else - { - let parsedSdp = sdpTransform.parse(answer.sdp); - let videoMedia; - - logger.debug('created answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp); - - for (let m of parsedSdp.media) - { - if (m.type === 'video' && m.direction === 'sendonly') - { - videoMedia = m; - break; - } - } - - if (!videoMedia) - return answer; - - logger.debug('setting video simulcast (Unified-Plan)'); - - videoMedia.simulcast_03 = - { - value : 'send rid=1,2' - }; - - videoMedia.rids = - [ - { id: '1', direction: 'send' }, - { id: '2', direction: 'send' } - ]; - - let modifiedAnswer = - { - type : 'answer', - sdp : sdpTransform.write(parsedSdp) - }; - - return modifiedAnswer; - } - }) - .then((answer) => - { - return this._peerconnection.setLocalDescription(answer); - }) - .then(() => - { - let answer = this._peerconnection.localDescription; - let parsedSdp = sdpTransform.parse(answer.sdp); - - logger.debug('sent answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp); - - accept( - { - answer : - { - type : answer.type, - sdp : answer.sdp - } - }); - }) - .catch((error) => - { - logger.error('"offer" request failed: %o', error); - - reject(500, error.message); - throw error; - }) - .then(() => - { - // If Firefox trigger 'forcestreamsupdate' event due to bug: - // https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 - if (browser.firefox || browser.gecko) - { - // Not sure, but it thinks that the timeout does the trick. - setTimeout(() => this.emit('forcestreamsupdate'), 500); - } - }); - - break; - } - - case 'activespeaker': - { - let data = request.data; - - this.emit('activespeaker', data.peer, data.level); - accept(); - - break; - } - - default: - { - logger.error('unknown method'); - - reject(404, 'unknown method'); - } - } - } - - _updateWebcams() - { - logger.debug('_updateWebcams()'); - - // Reset the list. - this._webcams = new Map(); - - return Promise.resolve() - .then(() => - { - return navigator.mediaDevices.enumerateDevices(); - }) - .then((devices) => - { - for (let device of devices) - { - if (device.kind !== 'videoinput') - continue; - - this._webcams.set(device.deviceId, device); - } - }) - .then(() => - { - let array = Array.from(this._webcams.values()); - let len = array.length; - let currentWebcamId = this._webcam ? this._webcam.deviceId : undefined; - - logger.debug('_updateWebcams() [webcams:%o]', array); - - if (len === 0) - this._webcam = null; - else if (!this._webcams.has(currentWebcamId)) - this._webcam = array[0]; - - this.emit('numwebcams', len); - - this._emitWebcamType(); - }); - } - - _getLocalStream(constraints) - { - logger.debug('_getLocalStream() [constraints:%o, webcam:%o]', - constraints, this._webcam); - - if (this._webcam) - constraints.video.deviceId = { exact: this._webcam.deviceId }; - - return navigator.mediaDevices.getUserMedia(constraints); - } - - _createPeerConnection() - { - logger.debug('_createPeerConnection()'); - - this._peerconnection = new RTCPeerConnection({ iceServers: [] }); - - // TODO: TMP - global.PC = this._peerconnection; - - if (this._localStream) - this._peerconnection.addStream(this._localStream); - - this._peerconnection.addEventListener('iceconnectionstatechange', () => - { - let state = this._peerconnection.iceConnectionState; - - if (state === 'failed') - logger.warn('peerconnection "iceconnectionstatechange" event [state:failed]'); - else - logger.debug('peerconnection "iceconnectionstatechange" event [state:%s]', state); - - this.emit('connectionstate', state); - }); - - this._peerconnection.addEventListener('addstream', (event) => - { - let stream = event.stream; - - logger.debug('peerconnection "addstream" event [stream:%o]', stream); - - this.emit('addstream', stream); - - // NOTE: For testing. - let interval = setInterval(() => - { - if (!stream.active) - { - logger.warn('stream inactive [stream:%o]', stream); - - clearInterval(interval); - } - }, 2000); - - stream.addEventListener('addtrack', (event) => - { - let track = event.track; - - logger.debug('stream "addtrack" event [track:%o]', track); - - this.emit('addtrack', track); - - // Firefox does not implement 'stream.onremovetrack' so let's use 'track.ended'. - // But... track "ended" is neither fired. - // https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 - track.addEventListener('ended', () => - { - logger.debug('track "ended" event [track:%o]', track); - - this.emit('removetrack', track); - }); - }); - - // NOTE: Not implemented in Firefox. - stream.addEventListener('removetrack', (event) => - { - let track = event.track; - - logger.debug('stream "removetrack" event [track:%o]', track); - - this.emit('removetrack', track); - }); - }); - - this._peerconnection.addEventListener('removestream', (event) => - { - let stream = event.stream; - - logger.debug('peerconnection "removestream" event [stream:%o]', stream); - - this.emit('removestream', stream); - }); - } - - _requestRenegotiation() - { - logger.debug('_requestRenegotiation()'); - - return this._protooPeer.send('reofferme'); - } - - _restartIce() - { - logger.debug('_restartIce()'); - - return this._protooPeer.send('restartice') - .then(() => - { - logger.debug('_restartIce() succeded'); - }) - .catch((error) => - { - logger.error('_restartIce() failed: %o', error); - - throw error; - }); - } - - _emitWebcamType() - { - let webcam = this._webcam; - - if (!webcam) - return; - - if (/(back|rear)/i.test(webcam.label)) - { - logger.debug('_emitWebcamType() | it seems to be a back camera'); - - this.emit('webcamtype', 'back'); - } - else - { - logger.debug('_emitWebcamType() | it seems to be a front camera'); - - this.emit('webcamtype', 'front'); - } - } -} diff --git a/app/lib/Logger.js b/app/lib/Logger.js index f2f9acf..7d1baa1 100644 --- a/app/lib/Logger.js +++ b/app/lib/Logger.js @@ -1,5 +1,3 @@ -'use strict'; - import debug from 'debug'; const APP_NAME = 'mediasoup-demo'; @@ -10,20 +8,22 @@ export default class Logger { if (prefix) { - this._debug = debug(APP_NAME + ':' + prefix); - this._warn = debug(APP_NAME + ':WARN:' + prefix); - this._error = debug(APP_NAME + ':ERROR:' + 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'); + 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() diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js new file mode 100644 index 0000000..268621e --- /dev/null +++ b/app/lib/RoomClient.js @@ -0,0 +1,1148 @@ +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'; + +const logger = new Logger('RoomClient'); + +const ROOM_OPTIONS = +{ + requestTimeout : 10000, + transportOptions : + { + tcp : false + } +}; + +const VIDEO_CONSTRAINS = +{ + qvga : { width: { ideal: 320 }, height: { ideal: 240 } }, + vga : { width: { ideal: 640 }, height: { ideal: 480 } }, + hd : { width: { ideal: 1280 }, height: { ideal: 720 } } +}; + +export default class RoomClient +{ + constructor( + { roomId, peerName, displayName, device, useSimulcast, produce, dispatch, getState }) + { + logger.debug( + 'constructor() [roomId:"%s", peerName:"%s", displayName:"%s", device:%s]', + roomId, peerName, displayName, device.flag); + + const protooUrl = getProtooUrl(peerName, roomId); + const protooTransport = new protooClient.WebSocketTransport(protooUrl); + + // Closed flag. + this._closed = false; + + // Whether we should produce. + this._produce = produce; + + // Whether simulcast should be used. + this._useSimulcast = useSimulcast; + + // Redux store dispatch function. + this._dispatch = dispatch; + + // Redux store getState function. + this._getState = getState; + + // My peer name. + this._peerName = peerName; + + // protoo-client Peer instance. + this._protoo = new protooClient.Peer(protooTransport); + + // mediasoup-client Room instance. + this._room = new mediasoupClient.Room(ROOM_OPTIONS); + + // Transport for sending. + this._sendTransport = null; + + // Transport for receiving. + this._recvTransport = null; + + // Local mic mediasoup Producer. + this._micProducer = null; + + // Local webcam mediasoup Producer. + this._webcamProducer = null; + + // Map of webcam MediaDeviceInfos indexed by deviceId. + // @type {Map} + this._webcams = new Map(); + + // Local Webcam. Object with: + // - {MediaDeviceInfo} [device] + // - {String} [resolution] - 'qvga' / 'vga' / 'hd'. + this._webcam = + { + device : null, + resolution : 'hd' + }; + + this._join({ displayName, device }); + } + + close() + { + if (this._closed) + return; + + this._closed = true; + + logger.debug('close()'); + + // Leave the mediasoup Room. + this._room.leave(); + + // Close protoo Peer (wait a bit so mediasoup-client can send + // the 'leaveRoom' notification). + setTimeout(() => this._protoo.close(), 250); + + this._dispatch(stateActions.setRoomState('closed')); + } + + changeDisplayName(displayName) + { + logger.debug('changeDisplayName() [displayName:"%s"]', displayName); + + // Store in cookie. + cookiesManager.setUser({ displayName }); + + return this._protoo.send('change-display-name', { displayName }) + .then(() => + { + this._dispatch( + stateActions.setDisplayName(displayName)); + + this._dispatch(requestActions.notify( + { + text : 'Display name changed' + })); + }) + .catch((error) => + { + logger.error('changeDisplayName() | failed: %o', error); + + this._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. + this._dispatch(stateActions.setDisplayName()); + }); + } + + muteMic() + { + logger.debug('muteMic()'); + + this._micProducer.pause(); + } + + unmuteMic() + { + logger.debug('unmuteMic()'); + + this._micProducer.resume(); + } + + enableWebcam() + { + logger.debug('enableWebcam()'); + + // Store in cookie. + cookiesManager.setDevices({ webcamEnabled: true }); + + this._dispatch( + stateActions.setWebcamInProgress(true)); + + return Promise.resolve() + .then(() => + { + return this._updateWebcams(); + }) + .then(() => + { + return this._setWebcamProducer(); + }) + .then(() => + { + this._dispatch( + stateActions.setWebcamInProgress(false)); + }) + .catch((error) => + { + logger.error('enableWebcam() | failed: %o', error); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }); + } + + disableWebcam() + { + logger.debug('disableWebcam()'); + + // Store in cookie. + cookiesManager.setDevices({ webcamEnabled: false }); + + this._dispatch( + stateActions.setWebcamInProgress(true)); + + return Promise.resolve() + .then(() => + { + this._webcamProducer.close(); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }) + .catch((error) => + { + logger.error('disableWebcam() | failed: %o', error); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }); + } + + changeWebcam() + { + logger.debug('changeWebcam()'); + + this._dispatch( + stateActions.setWebcamInProgress(true)); + + return Promise.resolve() + .then(() => + { + return this._updateWebcams(); + }) + .then(() => + { + 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'; + }) + .then(() => + { + const { device, resolution } = this._webcam; + + if (!device) + throw new Error('no webcam devices'); + + logger.debug('changeWebcam() | calling getUserMedia()'); + + return navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { exact: device.deviceId }, + ...VIDEO_CONSTRAINS[resolution] + } + }); + }) + .then((stream) => + { + const track = stream.getVideoTracks()[0]; + + return this._webcamProducer.replaceTrack(track) + .then((newTrack) => + { + track.stop(); + + return newTrack; + }); + }) + .then((newTrack) => + { + this._dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }) + .catch((error) => + { + logger.error('changeWebcam() failed: %o', error); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }); + } + + changeWebcamResolution() + { + logger.debug('changeWebcamResolution()'); + + let oldResolution; + let newResolution; + + this._dispatch( + stateActions.setWebcamInProgress(true)); + + return Promise.resolve() + .then(() => + { + oldResolution = this._webcam.resolution; + + switch (oldResolution) + { + case 'qvga': + newResolution = 'vga'; + break; + case 'vga': + newResolution = 'hd'; + break; + case 'hd': + newResolution = 'qvga'; + break; + } + + this._webcam.resolution = newResolution; + }) + .then(() => + { + const { device, resolution } = this._webcam; + + logger.debug('changeWebcamResolution() | calling getUserMedia()'); + + return navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { exact: device.deviceId }, + ...VIDEO_CONSTRAINS[resolution] + } + }); + }) + .then((stream) => + { + const track = stream.getVideoTracks()[0]; + + return this._webcamProducer.replaceTrack(track) + .then((newTrack) => + { + track.stop(); + + return newTrack; + }); + }) + .then((newTrack) => + { + this._dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + }) + .catch((error) => + { + logger.error('changeWebcamResolution() failed: %o', error); + + this._dispatch( + stateActions.setWebcamInProgress(false)); + + this._webcam.resolution = oldResolution; + }); + } + + enableAudioOnly() + { + logger.debug('enableAudioOnly()'); + + this._dispatch( + stateActions.setAudioOnlyInProgress(true)); + + return Promise.resolve() + .then(() => + { + if (this._webcamProducer) + this._webcamProducer.close(); + + for (const peer of this._room.peers) + { + for (const consumer of peer.consumers) + { + if (consumer.kind !== 'video') + continue; + + consumer.pause('audio-only-mode'); + } + } + + this._dispatch( + stateActions.setAudioOnlyState(true)); + + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); + }) + .catch((error) => + { + logger.error('enableAudioOnly() failed: %o', error); + + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); + }); + } + + disableAudioOnly() + { + logger.debug('disableAudioOnly()'); + + this._dispatch( + stateActions.setAudioOnlyInProgress(true)); + + return Promise.resolve() + .then(() => + { + if (!this._webcamProducer && this._room.canSend('video')) + return this.enableWebcam(); + }) + .then(() => + { + for (const peer of this._room.peers) + { + for (const consumer of peer.consumers) + { + if (consumer.kind !== 'video' || !consumer.supported) + continue; + + consumer.resume(); + } + } + + this._dispatch( + stateActions.setAudioOnlyState(false)); + + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); + }) + .catch((error) => + { + logger.error('disableAudioOnly() failed: %o', error); + + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); + }); + } + + restartIce() + { + logger.debug('restartIce()'); + + this._dispatch( + stateActions.setRestartIceInProgress(true)); + + return Promise.resolve() + .then(() => + { + this._room.restartIce(); + + // Make it artificially longer. + setTimeout(() => + { + this._dispatch( + stateActions.setRestartIceInProgress(false)); + }, 500); + }) + .catch((error) => + { + logger.error('restartIce() failed: %o', error); + + this._dispatch( + stateActions.setRestartIceInProgress(false)); + }); + } + + _join({ displayName, device }) + { + this._dispatch(stateActions.setRoomState('connecting')); + + this._protoo.on('open', () => + { + logger.debug('protoo Peer "open" event'); + + this._joinRoom({ displayName, device }); + }); + + this._protoo.on('disconnected', () => + { + logger.warn('protoo Peer "disconnected" event'); + + this._dispatch(requestActions.notify( + { + type : 'error', + text : 'WebSocket disconnected' + })); + + // Leave Room. + try { this._room.remoteClose({ cause: 'protoo disconnected' }); } + catch (error) {} + + this._dispatch(stateActions.setRoomState('connecting')); + }); + + this._protoo.on('close', () => + { + if (this._closed) + return; + + logger.warn('protoo Peer "close" event'); + + this.close(); + }); + + this._protoo.on('request', (request, accept, reject) => + { + logger.debug( + '_handleProtooRequest() [method:%s, data:%o]', + request.method, request.data); + + switch (request.method) + { + case 'mediasoup-notification': + { + accept(); + + const notification = request.data; + + this._room.receiveNotification(notification); + + break; + } + + case 'active-speaker': + { + accept(); + + const { peerName } = request.data; + + this._dispatch( + stateActions.setRoomActiveSpeaker(peerName)); + + break; + } + + case 'display-name-changed': + { + accept(); + + // eslint-disable-next-line no-shadow + const { peerName, displayName, oldDisplayName } = request.data; + + // NOTE: Hack, we shouldn't do this, but this is just a demo. + const peer = this._room.getPeerByName(peerName); + + if (!peer) + { + logger.error('peer not found'); + + break; + } + + peer.appData.displayName = displayName; + + this._dispatch( + stateActions.setPeerDisplayName(displayName, peerName)); + + this._dispatch(requestActions.notify( + { + text : `${oldDisplayName} is now ${displayName}` + })); + + break; + } + + default: + { + logger.error('unknown protoo method "%s"', request.method); + + reject(404, 'unknown method'); + } + } + }); + } + + _joinRoom({ displayName, device }) + { + logger.debug('_joinRoom()'); + + // NOTE: We allow rejoining (room.join()) the same mediasoup Room when Protoo + // WebSocket re-connects, so we must clean existing event listeners. Otherwise + // they will be called twice after the reconnection. + this._room.removeAllListeners(); + + this._room.on('close', (originator, appData) => + { + if (originator === 'remote') + { + logger.warn('mediasoup Peer/Room remotely closed [appData:%o]', appData); + + this._dispatch(stateActions.setRoomState('closed')); + + return; + } + }); + + this._room.on('request', (request, callback, errback) => + { + logger.debug( + 'sending mediasoup request [method:%s]:%o', request.method, request); + + this._protoo.send('mediasoup-request', request) + .then(callback) + .catch(errback); + }); + + this._room.on('notify', (notification) => + { + logger.debug( + 'sending mediasoup notification [method:%s]:%o', + notification.method, notification); + + this._protoo.send('mediasoup-notification', notification) + .catch((error) => + { + logger.warn('could not send mediasoup notification:%o', error); + }); + }); + + this._room.on('newpeer', (peer) => + { + logger.debug( + 'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer); + + this._handlePeer(peer); + }); + + this._room.join(this._peerName, { displayName, device }) + .then(() => + { + // Create Transport for sending. + this._sendTransport = + this._room.createTransport('send', { media: 'SEND_MIC_WEBCAM' }); + + this._sendTransport.on('close', (originator) => + { + logger.debug( + 'Transport "close" event [originator:%s]', originator); + }); + + // Create Transport for receiving. + this._recvTransport = + this._room.createTransport('recv', { media: 'RECV' }); + + this._recvTransport.on('close', (originator) => + { + logger.debug( + 'receiving Transport "close" event [originator:%s]', originator); + }); + }) + .then(() => + { + // Set our media capabilities. + this._dispatch(stateActions.setMediaCapabilities( + { + canSendMic : this._room.canSend('audio'), + canSendWebcam : this._room.canSend('video') + })); + }) + .then(() => + { + // Don't produce if explicitely requested to not to do it. + if (!this._produce) + return; + + // NOTE: Don't depend on this Promise to continue (so we don't do return). + Promise.resolve() + // Add our mic. + .then(() => + { + if (!this._room.canSend('audio')) + return; + + this._setMicProducer() + .catch(() => {}); + }) + // Add our webcam (unless the cookie says no). + .then(() => + { + if (!this._room.canSend('video')) + return; + + const devicesCookie = cookiesManager.getDevices(); + + if (!devicesCookie || devicesCookie.webcamEnabled) + this.enableWebcam(); + }); + }) + .then(() => + { + this._dispatch(stateActions.setRoomState('connected')); + + // Clean all the existing notifcations. + this._dispatch(stateActions.removeAllNotifications()); + + this._dispatch(requestActions.notify( + { + text : 'You are in the room', + timeout : 5000 + })); + + const peers = this._room.peers; + + for (const peer of peers) + { + this._handlePeer(peer, { notify: false }); + } + }) + .catch((error) => + { + logger.error('_joinRoom() failed:%o', error); + + this._dispatch(requestActions.notify( + { + type : 'error', + text : `Could not join the room: ${error.toString()}` + })); + + this.close(); + }); + } + + _setMicProducer() + { + if (!this._room.canSend('audio')) + { + return Promise.reject( + new Error('cannot send audio')); + } + + if (this._micProducer) + { + return Promise.reject( + new Error('mic Producer already exists')); + } + + let producer; + + return Promise.resolve() + .then(() => + { + logger.debug('_setMicProducer() | calling getUserMedia()'); + + return navigator.mediaDevices.getUserMedia({ audio: true }); + }) + .then((stream) => + { + const track = stream.getAudioTracks()[0]; + + producer = this._room.createProducer(track, null, { source: 'mic' }); + + // No need to keep original track. + track.stop(); + + // Send it. + return producer.send(this._sendTransport); + }) + .then(() => + { + this._micProducer = producer; + + this._dispatch(stateActions.addProducer( + { + id : producer.id, + source : 'mic', + locallyPaused : producer.locallyPaused, + remotelyPaused : producer.remotelyPaused, + track : producer.track, + codec : producer.rtpParameters.codecs[0].name + })); + + producer.on('close', (originator) => + { + logger.debug( + 'mic Producer "close" event [originator:%s]', originator); + + this._micProducer = null; + this._dispatch(stateActions.removeProducer(producer.id)); + }); + + producer.on('pause', (originator) => + { + logger.debug( + 'mic Producer "pause" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerPaused(producer.id, originator)); + }); + + producer.on('resume', (originator) => + { + logger.debug( + 'mic Producer "resume" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerResumed(producer.id, originator)); + }); + + producer.on('handled', () => + { + logger.debug('mic Producer "handled" event'); + }); + + producer.on('unhandled', () => + { + logger.debug('mic Producer "unhandled" event'); + }); + }) + .then(() => + { + logger.debug('_setMicProducer() succeeded'); + }) + .catch((error) => + { + logger.error('_setMicProducer() failed:%o', error); + + this._dispatch(requestActions.notify( + { + text : `Mic producer failed: ${error.name}:${error.message}` + })); + + if (producer) + producer.close(); + + throw error; + }); + } + + _setWebcamProducer() + { + if (!this._room.canSend('video')) + { + return Promise.reject( + new Error('cannot send video')); + } + + if (this._webcamProducer) + { + return Promise.reject( + new Error('webcam Producer already exists')); + } + + let producer; + + return Promise.resolve() + .then(() => + { + const { device, resolution } = this._webcam; + + if (!device) + throw new Error('no webcam devices'); + + logger.debug('_setWebcamProducer() | calling getUserMedia()'); + + return navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { exact: device.deviceId }, + ...VIDEO_CONSTRAINS[resolution] + } + }); + }) + .then((stream) => + { + const track = stream.getVideoTracks()[0]; + + producer = this._room.createProducer( + track, { simulcast: this._useSimulcast }, { source: 'webcam' }); + + // No need to keep original track. + track.stop(); + + // Send it. + return producer.send(this._sendTransport); + }) + .then(() => + { + this._webcamProducer = producer; + + const { device } = this._webcam; + + this._dispatch(stateActions.addProducer( + { + id : producer.id, + source : 'webcam', + deviceLabel : device.label, + type : this._getWebcamType(device), + locallyPaused : producer.locallyPaused, + remotelyPaused : producer.remotelyPaused, + track : producer.track, + codec : producer.rtpParameters.codecs[0].name + })); + + producer.on('close', (originator) => + { + logger.debug( + 'webcam Producer "close" event [originator:%s]', originator); + + this._webcamProducer = null; + this._dispatch(stateActions.removeProducer(producer.id)); + }); + + producer.on('pause', (originator) => + { + logger.debug( + 'webcam Producer "pause" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerPaused(producer.id, originator)); + }); + + producer.on('resume', (originator) => + { + logger.debug( + 'webcam Producer "resume" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerResumed(producer.id, originator)); + }); + + producer.on('handled', () => + { + logger.debug('webcam Producer "handled" event'); + }); + + producer.on('unhandled', () => + { + logger.debug('webcam Producer "unhandled" event'); + }); + }) + .then(() => + { + logger.debug('_setWebcamProducer() succeeded'); + }) + .catch((error) => + { + logger.error('_setWebcamProducer() failed:%o', error); + + this._dispatch(requestActions.notify( + { + text : `Webcam producer failed: ${error.name}:${error.message}` + })); + + if (producer) + producer.close(); + + throw error; + }); + } + + _updateWebcams() + { + logger.debug('_updateWebcams()'); + + // Reset the list. + this._webcams = new Map(); + + return Promise.resolve() + .then(() => + { + logger.debug('_updateWebcams() | calling enumerateDevices()'); + + return navigator.mediaDevices.enumerateDevices(); + }) + .then((devices) => + { + for (const device of devices) + { + if (device.kind !== 'videoinput') + continue; + + this._webcams.set(device.deviceId, device); + } + }) + .then(() => + { + 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]; + + this._dispatch( + stateActions.setCanChangeWebcam(this._webcams.size >= 2)); + }); + } + + _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'; + } + } + + _handlePeer(peer, { notify = true } = {}) + { + const displayName = peer.appData.displayName; + + this._dispatch(stateActions.addPeer( + { + name : peer.name, + displayName : displayName, + device : peer.appData.device, + consumers : [] + })); + + if (notify) + { + this._dispatch(requestActions.notify( + { + text : `${displayName} joined the room` + })); + } + + for (const consumer of peer.consumers) + { + this._handleConsumer(consumer); + } + + peer.on('close', (originator) => + { + logger.debug( + 'peer "close" event [name:"%s", originator:%s]', + peer.name, originator); + + this._dispatch(stateActions.removePeer(peer.name)); + + if (this._room.joined) + { + this._dispatch(requestActions.notify( + { + text : `${peer.appData.displayName} left the room` + })); + } + }); + + peer.on('newconsumer', (consumer) => + { + logger.debug( + 'peer "newconsumer" event [name:"%s", id:%s, consumer:%o]', + peer.name, consumer.id, consumer); + + this._handleConsumer(consumer); + }); + } + + _handleConsumer(consumer) + { + const codec = consumer.rtpParameters.codecs[0]; + + this._dispatch(stateActions.addConsumer( + { + id : consumer.id, + peerName : consumer.peer.name, + source : consumer.appData.source, + supported : consumer.supported, + locallyPaused : consumer.locallyPaused, + remotelyPaused : consumer.remotelyPaused, + track : null, + codec : codec ? codec.name : null + }, + consumer.peer.name)); + + consumer.on('close', (originator) => + { + logger.debug( + 'consumer "close" event [id:%s, originator:%s, consumer:%o]', + consumer.id, originator, consumer); + + this._dispatch(stateActions.removeConsumer( + consumer.id, consumer.peer.name)); + }); + + consumer.on('pause', (originator) => + { + logger.debug( + 'consumer "pause" event [id:%s, originator:%s, consumer:%o]', + consumer.id, originator, consumer); + + this._dispatch(stateActions.setConsumerPaused(consumer.id, originator)); + }); + + consumer.on('resume', (originator) => + { + logger.debug( + 'consumer "resume" event [id:%s, originator:%s, consumer:%o]', + consumer.id, originator, consumer); + + this._dispatch(stateActions.setConsumerResumed(consumer.id, originator)); + }); + + consumer.on('effectiveprofilechange', (profile) => + { + logger.debug( + 'consumer "effectiveprofilechange" event [id:%s, consumer:%o, profile:%s]', + consumer.id, consumer, profile); + + this._dispatch(stateActions.setConsumerEffectiveProfile(consumer.id, profile)); + }); + + // Receive the consumer (if we can). + if (consumer.supported) + { + // Pause it if video and we are in audio-only mode. + if (consumer.kind === 'video' && this._getState().me.audioOnly) + consumer.pause('audio-only-mode'); + + consumer.receive(this._recvTransport) + .then((track) => + { + this._dispatch(stateActions.setConsumerTrack(consumer.id, track)); + }) + .catch((error) => + { + logger.error( + 'unexpected error while receiving a new Consumer:%o', error); + }); + } + } +} diff --git a/app/lib/components/App.jsx b/app/lib/components/App.jsx deleted file mode 100644 index 000fee1..0000000 --- a/app/lib/components/App.jsx +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; - -import React from 'react'; -import PropTypes from 'prop-types'; -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; -import Logger from '../Logger'; -import muiTheme from './muiTheme'; -import Notifier from './Notifier'; -import Room from './Room'; - -const logger = new Logger('App'); // eslint-disable-line no-unused-vars - -export default class App extends React.Component -{ - constructor() - { - super(); - - this.state = {}; - } - - render() - { - let props = this.props; - - return ( - -
- - - -
-
- ); - } - - handleNotify(data) - { - this.refs.Notifier.notify(data); - } - - handleHideNotification(uid) - { - this.refs.Notifier.hideNotification(uid); - } -} - -App.propTypes = -{ - peerId : PropTypes.string.isRequired, - roomId : PropTypes.string.isRequired -}; diff --git a/app/lib/components/EditableInput.jsx b/app/lib/components/EditableInput.jsx new file mode 100644 index 0000000..addf1f4 --- /dev/null +++ b/app/lib/components/EditableInput.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { RIEInput } from 'riek'; + +export default class EditableInput extends React.Component +{ + render() + { + const { + value, + propName, + className, + classLoading, + classInvalid, + editProps, + onChange + } = this.props; + + return ( + onChange(data)} + /> + ); + } + + shouldComponentUpdate(nextProps) + { + if (nextProps.value === this.props.value) + return false; + + return true; + } +} + +EditableInput.propTypes = +{ + value : PropTypes.string, + propName : PropTypes.string.isRequired, + className : PropTypes.string, + classLoading : PropTypes.string, + classInvalid : PropTypes.string, + editProps : PropTypes.any, + onChange : PropTypes.func.isRequired +}; diff --git a/app/lib/components/LocalVideo.jsx b/app/lib/components/LocalVideo.jsx deleted file mode 100644 index 04d8f58..0000000 --- a/app/lib/components/LocalVideo.jsx +++ /dev/null @@ -1,156 +0,0 @@ -'use strict'; - -import React from 'react'; -import PropTypes from 'prop-types'; -import IconButton from 'material-ui/IconButton/IconButton'; -import MicOffIcon from 'material-ui/svg-icons/av/mic-off'; -import VideoCamOffIcon from 'material-ui/svg-icons/av/videocam-off'; -import ChangeVideoCamIcon from 'material-ui/svg-icons/av/repeat'; -import classnames from 'classnames'; -import Video from './Video'; -import Logger from '../Logger'; - -const logger = new Logger('LocalVideo'); // eslint-disable-line no-unused-vars - -export default class LocalVideo extends React.Component -{ - constructor(props) - { - super(props); - - this.state = - { - micMuted : false, - webcam : props.stream && !!props.stream.getVideoTracks()[0], - togglingWebcam : false - }; - } - - render() - { - let props = this.props; - let state = this.state; - - return ( -
- {props.stream ? -
- ); - } - - componentWillReceiveProps(nextProps) - { - this.setState({ webcam: nextProps.stream && !!nextProps.stream.getVideoTracks()[0] }); - } - - handleClickMuteMic() - { - logger.debug('handleClickMuteMic()'); - - let value = !this.state.micMuted; - - this.props.onMicMute(value) - .then(() => - { - this.setState({ micMuted: value }); - }); - } - - handleClickWebcam() - { - logger.debug('handleClickWebcam()'); - - let value = !this.state.webcam; - - this.setState({ togglingWebcam: true }); - - this.props.onWebcamToggle(value) - .then(() => - { - this.setState({ webcam: value, togglingWebcam: false }); - }) - .catch(() => - { - this.setState({ togglingWebcam: false }); - }); - } - - handleClickChangeWebcam() - { - logger.debug('handleClickChangeWebcam()'); - - this.props.onWebcamChange(); - } - - handleResolutionChange() - { - logger.debug('handleResolutionChange()'); - - this.props.onResolutionChange(); - } -} - -LocalVideo.propTypes = -{ - peerId : PropTypes.string.isRequired, - stream : PropTypes.object, - resolution : PropTypes.string, - multipleWebcams : PropTypes.bool.isRequired, - webcamType : PropTypes.string, - connectionState : PropTypes.string, - isActiveSpeaker : PropTypes.bool.isRequired, - onMicMute : PropTypes.func.isRequired, - onWebcamToggle : PropTypes.func.isRequired, - onWebcamChange : PropTypes.func.isRequired, - onResolutionChange : PropTypes.func.isRequired -}; diff --git a/app/lib/components/Me.jsx b/app/lib/components/Me.jsx new file mode 100644 index 0000000..36251c8 --- /dev/null +++ b/app/lib/components/Me.jsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ReactTooltip from 'react-tooltip'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { getDeviceInfo } from 'mediasoup-client'; +import * as appPropTypes from './appPropTypes'; +import * as requestActions from '../redux/requestActions'; +import PeerView from './PeerView'; + +class Me extends React.Component +{ + constructor(props) + { + super(props); + + this._mounted = false; + this._rootNode = null; + this._tooltip = true; + + // TODO: Issue when using react-tooltip in Edge: + // https://github.com/wwayne/react-tooltip/issues/328 + if (getDeviceInfo().flag === 'msedge') + this._tooltip = false; + } + + render() + { + const { + connected, + me, + micProducer, + webcamProducer, + onChangeDisplayName, + onMuteMic, + onUnmuteMic, + onEnableWebcam, + onDisableWebcam, + onChangeWebcam + } = this.props; + + let micState; + + if (!me.canSendMic) + micState = 'unsupported'; + else if (!micProducer) + micState = 'unsupported'; + else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) + micState = 'on'; + else + micState = 'off'; + + let webcamState; + + if (!me.canSendWebcam) + webcamState = 'unsupported'; + else if (webcamProducer) + webcamState = 'on'; + else + webcamState = 'off'; + + let changeWebcamState; + + if (Boolean(webcamProducer) && me.canChangeWebcam) + changeWebcamState = 'on'; + else + changeWebcamState = 'unsupported'; + + const videoVisible = ( + Boolean(webcamProducer) && + !webcamProducer.locallyPaused && + !webcamProducer.remotelyPaused + ); + + let tip; + + if (!me.displayNameSet) + tip = 'Click on your name to change it'; + + return ( +
(this._rootNode = node)} + data-tip={tip} + data-tip-disable={!tip} + data-type='dark' + > + {connected ? +
+
+ { + micState === 'on' ? onMuteMic() : onUnmuteMic(); + }} + /> + +
+ { + webcamState === 'on' ? onDisableWebcam() : onEnableWebcam(); + }} + /> + +
onChangeWebcam()} + /> +
+ :null + } + + onChangeDisplayName(displayName)} + /> + + {this._tooltip ? + + :null + } +
+ ); + } + + componentDidMount() + { + this._mounted = true; + + if (this._tooltip) + { + setTimeout(() => + { + if (!this._mounted || this.props.me.displayNameSet) + return; + + ReactTooltip.show(this._rootNode); + }, 4000); + } + } + + componentWillUnmount() + { + this._mounted = false; + } + + componentWillReceiveProps(nextProps) + { + if (this._tooltip) + { + if (nextProps.me.displayNameSet) + ReactTooltip.hide(this._rootNode); + } + } +} + +Me.propTypes = +{ + connected : PropTypes.bool.isRequired, + me : appPropTypes.Me.isRequired, + micProducer : appPropTypes.Producer, + webcamProducer : appPropTypes.Producer, + onChangeDisplayName : PropTypes.func.isRequired, + onMuteMic : PropTypes.func.isRequired, + onUnmuteMic : PropTypes.func.isRequired, + onEnableWebcam : PropTypes.func.isRequired, + onDisableWebcam : PropTypes.func.isRequired, + onChangeWebcam : PropTypes.func.isRequired +}; + +const mapStateToProps = (state) => +{ + const producersArray = Object.values(state.producers); + const micProducer = + producersArray.find((producer) => producer.source === 'mic'); + const webcamProducer = + producersArray.find((producer) => producer.source === 'webcam'); + + return { + connected : state.room.state === 'connected', + me : state.me, + micProducer : micProducer, + webcamProducer : webcamProducer + }; +}; + +const mapDispatchToProps = (dispatch) => +{ + return { + onChangeDisplayName : (displayName) => + { + dispatch(requestActions.changeDisplayName(displayName)); + }, + onMuteMic : () => dispatch(requestActions.muteMic()), + onUnmuteMic : () => dispatch(requestActions.unmuteMic()), + onEnableWebcam : () => dispatch(requestActions.enableWebcam()), + onDisableWebcam : () => dispatch(requestActions.disableWebcam()), + onChangeWebcam : () => dispatch(requestActions.changeWebcam()) + }; +}; + +const MeContainer = connect( + mapStateToProps, + mapDispatchToProps +)(Me); + +export default MeContainer; diff --git a/app/lib/components/Notifications.jsx b/app/lib/components/Notifications.jsx new file mode 100644 index 0000000..d850155 --- /dev/null +++ b/app/lib/components/Notifications.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import * as appPropTypes from './appPropTypes'; +import * as stateActions from '../redux/stateActions'; +import { Appear } from './transitions'; + +const Notifications = ({ notifications, onClick }) => +{ + return ( +
+ { + notifications.map((notification) => + { + return ( + +
onClick(notification.id)} + > +
+

{notification.text}

+
+ + ); + }) + } +
+ ); +}; + +Notifications.propTypes = +{ + notifications : PropTypes.arrayOf(appPropTypes.Notification).isRequired, + onClick : PropTypes.func.isRequired +}; + +const mapStateToProps = (state) => +{ + const { notifications } = state; + + return { notifications }; +}; + +const mapDispatchToProps = (dispatch) => +{ + return { + onClick : (notificationId) => + { + dispatch(stateActions.removeNotification(notificationId)); + } + }; +}; + +const NotificationsContainer = connect( + mapStateToProps, + mapDispatchToProps +)(Notifications); + +export default NotificationsContainer; diff --git a/app/lib/components/Notifier.jsx b/app/lib/components/Notifier.jsx deleted file mode 100644 index 8f4cbaf..0000000 --- a/app/lib/components/Notifier.jsx +++ /dev/null @@ -1,151 +0,0 @@ -'use strict'; - -import React from 'react'; -import NotificationSystem from 'react-notification-system'; - -const STYLE = -{ - NotificationItem : - { - DefaultStyle : - { - padding : '6px 10px', - backgroundColor : 'rgba(255,255,255, 0.9)', - fontFamily : 'Roboto', - fontWeight : 400, - fontSize : '1rem', - cursor : 'default', - WebkitUserSelect : 'none', - MozUserSelect : 'none', - userSelect : 'none', - transition : '0.15s ease-in-out' - }, - info : - { - color : '#000', - borderTop : '2px solid rgba(255,0,78, 0.75)' - }, - success : - { - color : '#000', - borderTop : '4px solid rgba(73,206,62, 0.75)' - }, - error : - { - color : '#000', - borderTop : '4px solid #ff0014' - } - }, - Title : - { - DefaultStyle : - { - margin : '0 0 8px 0', - fontFamily : 'Roboto', - fontWeight : 500, - fontSize : '1.1rem', - userSelect : 'none', - WebkitUserSelect : 'none', - MozUserSelect : 'none' - }, - info : - { - color : 'rgba(255,0,78, 0.85)' - }, - success : - { - color : 'rgba(73,206,62, 0.9)' - }, - error : - { - color : '#ff0014' - } - }, - Dismiss : - { - DefaultStyle : - { - display : 'none' - } - }, - Action : - { - DefaultStyle : - { - padding : '8px 24px', - fontSize : '1.2rem', - cursor : 'pointer', - userSelect : 'none', - WebkitUserSelect : 'none', - MozUserSelect : 'none' - }, - info : - { - backgroundColor : 'rgba(255,0,78, 1)' - }, - success : - { - backgroundColor : 'rgba(73,206,62, 0.75)' - } - } -}; - -export default class Notifier extends React.Component -{ - constructor(props) - { - super(props); - } - - render() - { - return ( - - ); - } - - notify(data) - { - let data2; - - switch (data.level) - { - case 'info' : - data2 = Object.assign( - { - position : 'tr', - dismissible : true, - autoDismiss : 1 - }, data); - break; - - case 'success' : - data2 = Object.assign( - { - position : 'tr', - dismissible : true, - autoDismiss : 1 - }, data); - break; - - case 'error' : - data2 = Object.assign( - { - position : 'tr', - dismissible : true, - autoDismiss : 3 - }, data); - break; - - default: - throw new Error(`unknown level "${data.level}"`); - } - - this.refs.NotificationSystem.addNotification(data2); - } - - hideNotification(uid) - { - this.refs.NotificationSystem.removeNotification(uid); - } -} diff --git a/app/lib/components/Peer.jsx b/app/lib/components/Peer.jsx new file mode 100644 index 0000000..61ed633 --- /dev/null +++ b/app/lib/components/Peer.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import * as appPropTypes from './appPropTypes'; +import PeerView from './PeerView'; + +const Peer = (props) => +{ + const { + peer, + micConsumer, + webcamConsumer + } = props; + + const micEnabled = ( + Boolean(micConsumer) && + !micConsumer.locallyPaused && + !micConsumer.remotelyPaused + ); + + const videoVisible = ( + Boolean(webcamConsumer) && + !webcamConsumer.locallyPaused && + !webcamConsumer.remotelyPaused + ); + + let videoProfile; + + if (webcamConsumer) + videoProfile = webcamConsumer.profile; + + return ( +
+
+ {!micEnabled ? +
+ :null + } + {!videoVisible ? +
+ :null + } +
+ + {videoVisible && !webcamConsumer.supported ? +
+

incompatible video

+
+ :null + } + + +
+ ); +}; + +Peer.propTypes = +{ + peer : appPropTypes.Peer.isRequired, + micConsumer : appPropTypes.Consumer, + webcamConsumer : appPropTypes.Consumer +}; + +const mapStateToProps = (state, { name }) => +{ + const peer = state.peers[name]; + const consumersArray = peer.consumers + .map((consumerId) => state.consumers[consumerId]); + const micConsumer = + consumersArray.find((consumer) => consumer.source === 'mic'); + const webcamConsumer = + consumersArray.find((consumer) => consumer.source === 'webcam'); + + return { + peer, + micConsumer, + webcamConsumer + }; +}; + +const PeerContainer = connect(mapStateToProps)(Peer); + +export default PeerContainer; diff --git a/app/lib/components/PeerView.jsx b/app/lib/components/PeerView.jsx new file mode 100644 index 0000000..840badd --- /dev/null +++ b/app/lib/components/PeerView.jsx @@ -0,0 +1,260 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import Spinner from 'react-spinner'; +import hark from 'hark'; +import * as appPropTypes from './appPropTypes'; +import EditableInput from './EditableInput'; + +export default class PeerView extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + volume : 0, // Integer from 0 to 10., + videoWidth : null, + videoHeight : null + }; + + // Latest received video track. + // @type {MediaStreamTrack} + this._audioTrack = null; + + // Latest received video track. + // @type {MediaStreamTrack} + this._videoTrack = null; + + // Hark instance. + // @type {Object} + this._hark = null; + + // Periodic timer for showing video resolution. + this._videoResolutionTimer = null; + } + + render() + { + const { + isMe, + peer, + videoVisible, + videoProfile, + audioCodec, + videoCodec, + onChangeDisplayName + } = this.props; + + const { + volume, + videoWidth, + videoHeight + } = this.state; + + return ( +
+
+
+
+ {audioCodec ? +

{audioCodec}

+ :null + } + + {videoCodec ? +

{videoCodec} {videoProfile}

+ :null + } + + {(videoVisible && videoWidth !== null) ? +

{videoWidth}x{videoHeight}

+ :null + } +
+
+ +
+ {isMe ? + onChangeDisplayName(displayName)} + /> + : + + {peer.displayName} + + } + +
+ + + {peer.device.name} {Math.floor(peer.device.version) || null} + +
+
+
+ +