commit f1658f1b3cf4231b7537b94fc0b3dbf4a12685cc Author: Iñaki Baz Castillo Date: Sun Apr 23 14:54:30 2017 +0200 Make demo public diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5396725 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..12e1e69 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# mediasoup-demo + +A demo of [mediasoup](https://mediasoup.org). + +Try it online at https://demo.mediasoup.org. + + +## Installation + +* Clone the project: + +```bash +$ git clone https://github.com/versatica/mediasoup-demo.git +$ cd mediasoup-demo +``` + +* Set up the server: + +```bash +$ cd server +$ npm install +``` + +* Copy `config.example.js` as `config.js`: + +```bash +$ cp config.example.js config.js +``` + +* Set up the browser app: + +```bash +$ 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 +$ npm install -g gulp-cli +``` + + +## Run it locally + +* Run the Node.js server application in a terminal: + +```bash +$ cd server +$ node server.js +``` + +* In another terminal build and run the browser application: + +```bash +$ cd app +$ gulp live +``` + +* Enjoy. + + +## Deploy it in a server + +* Build the production ready browser application: + +```bash +$ cd app +$ 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, 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: + +```bash +$ forever start PATH_TO_SERVER_FOLDER/server.js +``` + + +## Author + +* Iñaki Baz Castillo [[website](https://inakibaz.me)|[github](https://github.com/ibc/)] + + +## License + +All Rights Reserved. + 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/config.example.js b/app/config.example.js new file mode 100644 index 0000000..87bf913 --- /dev/null +++ b/app/config.example.js @@ -0,0 +1,7 @@ +module.exports = +{ + protoo : + { + listenPort : 3443 + } +}; diff --git a/app/gulpfile.js b/app/gulpfile.js new file mode 100644 index 0000000..000965d --- /dev/null +++ b/app/gulpfile.js @@ -0,0 +1,382 @@ +'use strict'; + +/** + * Tasks: + * + * gulp prod + * Generates the browser app in production mode. + * + * gulp dev + * Generates the browser app in development mode. + * + * gulp live + * Generates the browser app in development mode, opens it and watches + * for changes in the source code. + * + * gulp + * Alias for `gulp live`. + */ + +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 touch = require('gulp-touch'); +const rename = require('gulp-rename'); +const header = require('gulp-header'); +const browserify = require('browserify'); +const watchify = require('watchify'); +const envify = require('envify/custom'); +const uglify = require('gulp-uglify'); +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.json'); +const BANNER = fs.readFileSync('banner.txt').toString(); +const BANNER_OPTIONS = +{ + pkg : PKG, + currentYear : (new Date()).getFullYear() +}; +const OUTPUT_DIR = '../server/public'; + +// Default environment. +process.env.NODE_ENV = 'development'; + +function logError(error) +{ + gutil.log(gutil.colors.red(String(error))); + + throw error; +} + +function bundle(options) +{ + options = options || {}; + + let watch = !!options.watch; + let bundler = browserify( + { + entries : path.join(__dirname, 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', + { + presets : [ 'es2015', 'react' ], + plugins : [ 'transform-runtime', 'transform-object-assign' ] + }) + .transform(envify( + { + NODE_ENV : process.env.NODE_ENV, + _ : 'purge' + })); + + if (watch) + { + bundler = watchify(bundler); + + bundler.on('update', () => + { + let start = Date.now(); + + gutil.log('bundling...'); + rebundle(); + gutil.log('bundle took %sms', (Date.now() - start)); + }); + } + + function rebundle() + { + return bundler.bundle() + .on('error', logError) + .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('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' ]; + + return gulp.src(src) + .pipe(plumber()) + .pipe(eslint( + { + plugins : [ 'react', 'import' ], + extends : [ 'eslint:recommended', 'plugin:react/recommended' ], + settings : + { + react : + { + pragma : 'React', // Pragma to use, default to 'React'. + version : '15' // React version, default to the latest React stable release. + } + }, + parserOptions : + { + ecmaVersion : 6, + sourceType : 'module', + ecmaFeatures : + { + impliedStrict : true, + jsx : true + } + }, + envs : + [ + 'browser', + 'es6', + 'node', + 'commonjs' + ], + 'rules' : + { + 'no-console' : 0, + 'no-undef' : 2, + 'no-unused-vars' : [ 2, { 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 + } + })) + .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) => +{ + let 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('livebrowser', (done) => +{ + const config = require('../server/config'); + + browserSync( + { + open : 'external', + host : config.domain, + server : + { + baseDir : OUTPUT_DIR + }, + https : config.tls, + ghostMode : false, + files : path.join(OUTPUT_DIR, '**', '*') + }); + + done(); +}); + +gulp.task('browser', (done) => +{ + const config = require('../server/config'); + + browserSync( + { + open : 'external', + host : config.domain, + server : + { + baseDir : OUTPUT_DIR + }, + https : config.tls, + ghostMode : false + }); + + done(); +}); + +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('prod', gulp.series( + 'env:prod', + 'clean', + 'lint', + 'bundle', + 'html', + 'css', + 'resources' +)); + +gulp.task('dev', gulp.series( + 'env:dev', + 'clean', + 'lint', + 'bundle', + 'html', + 'css', + 'resources' +)); + +gulp.task('live', gulp.series( + 'env:dev', + 'clean', + 'lint', + 'bundle:watch', + 'html', + 'css', + 'resources', + 'watch', + 'livebrowser' +)); + +gulp.task('open', gulp.series('browser')); + +gulp.task('default', gulp.series('live')); diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..85a16d6 --- /dev/null +++ b/app/index.html @@ -0,0 +1,29 @@ + + + + + mediasoup demo + + + + + + + + + + + + +
+
+ + diff --git a/app/lib/Client.js b/app/lib/Client.js new file mode 100644 index 0000000..5f1c056 --- /dev/null +++ b/app/lib/Client.js @@ -0,0 +1,897 @@ +'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 urlFactory from './urlFactory'; +import 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); + + 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')); + } + + stream.removeTrack(videoTrack); + videoTrack.stop(); + + // NOTE: For Firefox (modern WenRTC API). + if (this._peerconnection.removeTrack) + { + let sender; + + for (sender of this._peerconnection.getSenders()) + { + if (sender.track === videoTrack) + break; + } + + this._peerconnection.removeTrack(sender); + } + + 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); + + // NOTE: For Firefox (modern WenRTC API). + if (this._peerconnection.addTrack) + this._peerconnection.addTrack(newVideoTrack, this._localStream); + } + else + { + this._localStream = newStream; + this._peerconnection.addStream(newStream); + } + + 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) + { + this._protooPeer.send('disableremotevideo', { msid, disable: true }) + .catch((error) => + { + logger.warn('disableRemoteVideo() failed: %o', error); + }); + } + + enableRemoteVideo(msid) + { + 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; + } + + 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.CLIENT = this; + global.PC = this._peerconnection; + + if (this._localStream) + this._peerconnection.addStream(this._localStream); + + this._peerconnection.addEventListener('iceconnectionstatechange', () => + { + let state = this._peerconnection.iceConnectionState; + + 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 new file mode 100644 index 0000000..f2f9acf --- /dev/null +++ b/app/lib/Logger.js @@ -0,0 +1,43 @@ +'use strict'; + +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'); + } + + this._debug.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + } + + get debug() + { + return this._debug; + } + + get warn() + { + return this._warn; + } + + get error() + { + return this._error; + } +} diff --git a/app/lib/components/App.jsx b/app/lib/components/App.jsx new file mode 100644 index 0000000..bc357ab --- /dev/null +++ b/app/lib/components/App.jsx @@ -0,0 +1,56 @@ +'use strict'; + +import React from 'react'; +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 : React.PropTypes.string.isRequired, + roomId : React.PropTypes.string.isRequired +}; diff --git a/app/lib/components/LocalVideo.jsx b/app/lib/components/LocalVideo.jsx new file mode 100644 index 0000000..9370a40 --- /dev/null +++ b/app/lib/components/LocalVideo.jsx @@ -0,0 +1,148 @@ +'use strict'; + +import React from 'react'; +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 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 : React.PropTypes.string.isRequired, + stream : React.PropTypes.object, + resolution : React.PropTypes.string, + multipleWebcams : React.PropTypes.bool.isRequired, + webcamType : React.PropTypes.string, + connectionState : React.PropTypes.string, + onMicMute : React.PropTypes.func.isRequired, + onWebcamToggle : React.PropTypes.func.isRequired, + onWebcamChange : React.PropTypes.func.isRequired, + onResolutionChange : React.PropTypes.func.isRequired +}; diff --git a/app/lib/components/Notifier.jsx b/app/lib/components/Notifier.jsx new file mode 100644 index 0000000..8f4cbaf --- /dev/null +++ b/app/lib/components/Notifier.jsx @@ -0,0 +1,151 @@ +'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/RemoteVideo.jsx b/app/lib/components/RemoteVideo.jsx new file mode 100644 index 0000000..8939616 --- /dev/null +++ b/app/lib/components/RemoteVideo.jsx @@ -0,0 +1,101 @@ +'use strict'; + +import React from 'react'; +import IconButton from 'material-ui/IconButton/IconButton'; +import VolumeOffIcon from 'material-ui/svg-icons/av/volume-off'; +import VideoOffIcon from 'material-ui/svg-icons/av/videocam-off'; +import classnames from 'classnames'; +import Video from './Video'; +import Logger from '../Logger'; + +const logger = new Logger('RemoteVideo'); // eslint-disable-line no-unused-vars + +export default class RemoteVideo extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + audioMuted : false + }; + } + + render() + { + let props = this.props; + let state = this.state; + let hasVideo = !!props.stream.getVideoTracks()[0]; + + global.SS = props.stream; + + return ( +
+
+ ); + } + + handleClickMuteAudio() + { + logger.debug('handleClickMuteAudio()'); + + let value = !this.state.audioMuted; + + this.setState({ audioMuted: value }); + } + + handleClickDisableVideo() + { + logger.debug('handleClickDisableVideo()'); + + let stream = this.props.stream; + let msid = stream.id; + let hasVideo = !!stream.getVideoTracks()[0]; + + if (hasVideo) + this.props.onDisableVideo(msid); + else + this.props.onEnableVideo(msid); + } +} + +RemoteVideo.propTypes = +{ + peer : React.PropTypes.object.isRequired, + stream : React.PropTypes.object.isRequired, + fullsize : React.PropTypes.bool, + onDisableVideo : React.PropTypes.func.isRequired, + onEnableVideo : React.PropTypes.func.isRequired +}; diff --git a/app/lib/components/Room.jsx b/app/lib/components/Room.jsx new file mode 100644 index 0000000..0e4d4a8 --- /dev/null +++ b/app/lib/components/Room.jsx @@ -0,0 +1,487 @@ +'use strict'; + +import React from 'react'; +import ClipboardButton from 'react-clipboard.js'; +import TransitionAppear from './TransitionAppear'; +import LocalVideo from './LocalVideo'; +import RemoteVideo from './RemoteVideo'; +import Stats from './Stats'; +import Logger from '../Logger'; +import utils from '../utils'; +import Client from '../Client'; + +const logger = new Logger('Room'); +const STATS_INTERVAL = 1000; + +export default class Room extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + peers : {}, + localStream : null, + localVideoResolution : null, // qvga / vga / hd / fullhd. + multipleWebcams : false, + webcamType : null, + connectionState : null, + remoteStreams : {}, + showStats : false, + stats : null + }; + + // Mounted flag + this._mounted = false; + // Client instance + this._client = null; + // Timer to retrieve RTC stats. + this._statsTimer = null; + } + + render() + { + let props = this.props; + let state = this.state; + let numPeers = Object.keys(state.remoteStreams).length; + + return ( + +
+ +
+
+ {}} // Avoid link action. + > + invite people to this room + +
+
+ +
+ { + Object.keys(state.remoteStreams).map((msid) => + { + let stream = state.remoteStreams[msid]; + let peer; + + for (let peerId of Object.keys(state.peers)) + { + peer = state.peers[peerId]; + + if (peer.msids.indexOf(msid) !== -1) + break; + } + + if (!peer) + return; + + return ( + + + + ); + }) + } +
+ + +
+ + + {state.showStats ? + + + + : +
+ } +
+ +
+
+ ); + } + + componentDidMount() + { + // Set flag + this._mounted = true; + + // Run the client + this._runClient(); + } + + componentWillUnmount() + { + let state = this.state; + + // Unset flag + this._mounted = false; + + // Close client + this._client.removeAllListeners(); + this._client.close(); + + // Close local MediaStream + if (state.localStream) + utils.closeMediaStream(state.localStream); + } + + handleRoomLinkCopied() + { + logger.debug('handleRoomLinkCopied()'); + + this.props.onNotify( + { + level : 'success', + position : 'tr', + title : 'Room URL copied to the clipboard', + message : 'Share it with others to join this room' + }); + } + + handleLocalMute(value) + { + logger.debug('handleLocalMute() [value:%s]', value); + + let micTrack = this.state.localStream.getAudioTracks()[0]; + + if (!micTrack) + return Promise.reject(new Error('no audio track')); + + micTrack.enabled = !value; + + return Promise.resolve(); + } + + handleLocalWebcamToggle(value) + { + logger.debug('handleLocalWebcamToggle() [value:%s]', value); + + return Promise.resolve() + .then(() => + { + if (value) + return this._client.addVideo(); + else + return this._client.removeVideo(); + }) + .then(() => + { + let localStream = this.state.localStream; + + this.setState({ localStream }); + }); + } + + handleLocalWebcamChange() + { + logger.debug('handleLocalWebcamChange()'); + + this._client.changeWebcam(); + } + + handleLocalResolutionChange() + { + logger.debug('handleLocalResolutionChange()'); + + this._client.changeVideoResolution(); + } + + handleStatsClose() + { + logger.debug('handleStatsClose()'); + + this.setState({ showStats: false }); + this._stopStats(); + } + + handleClickShowStats() + { + logger.debug('handleClickShowStats()'); + + this.setState({ showStats: true }); + this._startStats(); + } + + handleDisableRemoteVideo(msid) + { + logger.debug('handleDisableRemoteVideo() [msid:"%s"]', msid); + + this._client.disableRemoteVideo(msid); + } + + handleEnableRemoteVideo(msid) + { + logger.debug('handleEnableRemoteVideo() [msid:"%s"]', msid); + + this._client.enableRemoteVideo(msid); + } + + _runClient() + { + let peerId = this.props.peerId; + let roomId = this.props.roomId; + + logger.debug('_runClient() [peerId:"%s", roomId:"%s"]', peerId, roomId); + + this._client = new Client(peerId, roomId); + + this._client.on('localstream', (stream, resolution) => + { + this.setState( + { + localStream : stream, + localVideoResolution : resolution + }); + }); + + this._client.on('join', () => + { + // Clear remote streams (for reconnections). + this.setState({ remoteStreams: {} }); + + this.props.onNotify( + { + level : 'success', + title : 'Yes!', + message : 'You are in the room!', + image : '/resources/images/room.svg', + imageWidth : 80, + imageHeight : 80 + }); + + // Start retrieving WebRTC stats (unless mobile) + if (utils.isDesktop()) + { + this.setState({ showStats: true }); + + setTimeout(() => + { + this._startStats(); + }, STATS_INTERVAL / 2); + } + }); + + this._client.on('close', (error) => + { + // Clear remote streams (for reconnections). + this.setState({ remoteStreams: {} }); + + if (error) + { + this.props.onNotify( + { + level : 'error', + title : 'Error', + message : error.message + }); + } + + // Stop retrieving WebRTC stats. + this._stopStats(); + }); + + this._client.on('disconnected', () => + { + // Clear remote streams (for reconnections). + this.setState({ remoteStreams: {} }); + + this.props.onNotify( + { + level : 'error', + title : 'Warning', + message : 'app disconnected' + }); + + // Stop retrieving WebRTC stats. + this._stopStats(); + }); + + this._client.on('numwebcams', (num) => + { + this.setState( + { + multipleWebcams : (num > 1 ? true : false) + }); + }); + + this._client.on('webcamtype', (type) => + { + this.setState({ webcamType: type }); + }); + + this._client.on('peers', (peers) => + { + let peersObject = {}; + + for (let peer of peers) + { + peersObject[peer.id] = peer; + } + + this.setState({ peers: peersObject }); + }); + + this._client.on('addpeer', (peer) => + { + this.props.onNotify( + { + level : 'success', + message : `${peer.id} joined the room` + }); + + let peers = this.state.peers; + + peers[peer.id] = peer; + this.setState({ peers }); + }); + + this._client.on('updatepeer', (peer) => + { + let peers = this.state.peers; + + peers[peer.id] = peer; + this.setState({ peers }); + }); + + this._client.on('removepeer', (peer) => + { + this.props.onNotify( + { + level : 'info', + message : `${peer.id} left the room` + }); + + let peers = this.state.peers; + + delete peers[peer.id]; + this.setState({ peers }); + }); + + this._client.on('connectionstate', (state) => + { + this.setState({ connectionState: state }); + }); + + this._client.on('addstream', (stream) => + { + let remoteStreams = this.state.remoteStreams; + + remoteStreams[stream.id] = stream; + this.setState({ remoteStreams }); + }); + + this._client.on('removestream', (stream) => + { + let remoteStreams = this.state.remoteStreams; + + delete remoteStreams[stream.id]; + this.setState({ remoteStreams }); + }); + + this._client.on('addtrack', () => + { + let remoteStreams = this.state.remoteStreams; + + this.setState({ remoteStreams }); + }); + + this._client.on('removetrack', () => + { + let remoteStreams = this.state.remoteStreams; + + this.setState({ remoteStreams }); + }); + + this._client.on('forcestreamsupdate', () => + { + // Just firef for Firefox due to bug: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 + this.forceUpdate(); + }); + } + + _startStats() + { + logger.debug('_startStats()'); + + getStats.call(this); + + function getStats() + { + this._client.getStats() + .then((stats) => + { + if (!this._mounted) + return; + + this.setState({ stats }); + + this._statsTimer = setTimeout(() => + { + getStats.call(this); + }, STATS_INTERVAL); + }) + .catch((error) => + { + logger.error('getStats() failed: %o', error); + + this.setState({ stats: null }); + + this._statsTimer = setTimeout(() => + { + getStats.call(this); + }, STATS_INTERVAL); + }); + } + } + + _stopStats() + { + logger.debug('_stopStats()'); + + this.setState({ stats: null }); + + clearTimeout(this._statsTimer); + } +} + +Room.propTypes = +{ + peerId : React.PropTypes.string.isRequired, + roomId : React.PropTypes.string.isRequired, + onNotify : React.PropTypes.func.isRequired, + onHideNotification : React.PropTypes.func.isRequired +}; diff --git a/app/lib/components/Stats.jsx b/app/lib/components/Stats.jsx new file mode 100644 index 0000000..9094da2 --- /dev/null +++ b/app/lib/components/Stats.jsx @@ -0,0 +1,497 @@ +'use strict'; + +import React from 'react'; +import browser from 'bowser'; +import Logger from '../Logger'; + +const logger = new Logger('Stats'); // eslint-disable-line no-unused-vars + +// TODO: TMP +global.BROWSER = browser; + +export default class Stats extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + stats : + { + transport : null, + audio : null, + video : null + } + }; + } + + componentWillMount() + { + let stats = this.props.stats; + + this._processStats(stats); + } + + componentWillReceiveProps(nextProps) + { + let stats = nextProps.stats; + + this._processStats(stats); + } + + render() + { + let state = this.state; + + return ( +
+
+ { + Object.keys(state.stats).map((blockName) => + { + let block = state.stats[blockName]; + + if (!block) + return; + + let items = Object.keys(block).map((itemName) => + { + let value = block[itemName]; + + if (value === undefined) + return; + + return ( +
+
{itemName}
+
{value}
+
+ ); + }); + + if (!items.length) + return null; + + return ( +
+

{blockName}

+ {items} +
+ ); + }) + } +
+ ); + } + + handleCloseClick() + { + this.props.onClose(); + } + + _processStats(stats) + { + if (browser.check({ chrome: '58' }, true)) + { + this._processStatsChrome58(stats); + } + else if (browser.check({ chrome: '40' }, true)) + { + this._processStatsChromeOld(stats); + } + else if (browser.check({ firefox: '40' }, true)) + { + this._processStatsFirefox(stats); + } + else + { + logger.warn('_processStats() | unsupported browser [name:"%s", version:%s]', + browser.name, browser.version); + } + } + + _processStatsChrome58(stats) + { + let transport = {}; + let audio = {}; + let video = {}; + let selectedCandidatePair = null; + let localCandidates = {}; + let remoteCandidates = {}; + + for (let group of stats.values()) + { + switch (group.type) + { + case 'transport': + { + transport['bytes sent'] = group.bytesSent; + transport['bytes received'] = group.bytesReceived; + + break; + } + + case 'candidate-pair': + { + if (!group.writable) + break; + + selectedCandidatePair = group; + + transport['available bitrate'] = + Math.round(group.availableOutgoingBitrate / 1000) + ' kbps'; + transport['current RTT'] = + Math.round(group.currentRoundTripTime * 1000) + ' ms'; + + break; + } + + case 'local-candidate': + { + localCandidates[group.id] = group; + + break; + } + + case 'remote-candidate': + { + remoteCandidates[group.id] = group; + + break; + } + + case 'codec': + { + let mimeType = group.mimeType.split('/'); + let kind = mimeType[0]; + let codec = mimeType[1]; + let block; + + switch (kind) + { + case 'audio': + block = audio; + break; + case 'video': + block = video; + break; + } + + if (!block) + break; + + block['codec'] = codec; + block['payload type'] = group.payloadType; + + break; + } + + case 'track': + { + if (group.kind !== 'video') + break; + + video['frame size'] = group.frameWidth + ' x ' + group.frameHeight; + video['frames sent'] = group.framesSent; + + break; + } + + case 'outbound-rtp': + { + if (group.isRemote) + break; + + let block; + + switch (group.mediaType) + { + case 'audio': + block = audio; + break; + case 'video': + block = video; + break; + } + + if (!block) + break; + + block['ssrc'] = group.ssrc; + block['bytes sent'] = group.bytesSent; + block['packets sent'] = group.packetsSent; + + if (block === video) + block['frames encoded'] = group.framesEncoded; + + block['NACK count'] = group.nackCount; + block['PLI count'] = group.pliCount; + block['FIR count'] = group.firCount; + + break; + } + } + } + + // Post checks. + + if (!video.ssrc) + video = {}; + + if (!audio.ssrc) + audio = {}; + + if (selectedCandidatePair) + { + let localCandidate = localCandidates[selectedCandidatePair.localCandidateId]; + let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId]; + + transport['protocol'] = localCandidate.protocol; + transport['local IP'] = localCandidate.ip; + transport['local port'] = localCandidate.port; + transport['remote IP'] = remoteCandidate.ip; + transport['remote port'] = remoteCandidate.port; + } + + // Set state. + this.setState( + { + stats : + { + transport, + audio, + video + } + }); + } + + _processStatsChromeOld(stats) + { + let transport = {}; + let audio = {}; + let video = {}; + + for (let group of stats.values()) + { + switch (group.type) + { + case 'googCandidatePair': + { + if (group.googActiveConnection !== 'true') + break; + + let localAddress = group.googLocalAddress.split(':'); + let remoteAddress = group.googRemoteAddress.split(':'); + let localIP = localAddress[0]; + let localPort = localAddress[1]; + let remoteIP = remoteAddress[0]; + let remotePort = remoteAddress[1]; + + transport['protocol'] = group.googTransportType; + transport['local IP'] = localIP; + transport['local port'] = localPort; + transport['remote IP'] = remoteIP; + transport['remote port'] = remotePort; + transport['bytes sent'] = group.bytesSent; + transport['bytes received'] = group.bytesReceived; + transport['RTT'] = Math.round(group.googRtt) + ' ms'; + + break; + } + + case 'VideoBwe': + { + transport['available bitrate'] = + Math.round(group.googAvailableSendBandwidth / 1000) + ' kbps'; + transport['transmit bitrate'] = + Math.round(group.googTransmitBitrate / 1000) + ' kbps'; + + break; + } + + case 'ssrc': + { + if (group.packetsSent === undefined) + break; + + let block; + + switch (group.mediaType) + { + case 'audio': + block = audio; + break; + case 'video': + block = video; + break; + } + + if (!block) + break; + + block['codec'] = group.googCodecName; + block['ssrc'] = group.ssrc; + block['bytes sent'] = group.bytesSent; + block['packets sent'] = group.packetsSent; + block['packets lost'] = group.packetsLost; + + if (block === video) + { + block['frames encoded'] = group.framesEncoded; + video['frame size'] = + group.googFrameWidthSent + ' x ' + group.googFrameHeightSent; + video['frame rate'] = group.googFrameRateSent; + } + + block['NACK count'] = group.googNacksReceived; + block['PLI count'] = group.googPlisReceived; + block['FIR count'] = group.googFirsReceived; + + break; + } + } + } + + // Post checks. + + if (!video.ssrc) + video = {}; + + if (!audio.ssrc) + audio = {}; + + // Set state. + this.setState( + { + stats : + { + transport, + audio, + video + } + }); + } + + _processStatsFirefox(stats) + { + let transport = {}; + let audio = {}; + let video = {}; + let selectedCandidatePair = null; + let localCandidates = {}; + let remoteCandidates = {}; + + for (let group of stats.values()) + { + // TODO: REMOVE + global.STATS = stats; + + switch (group.type) + { + case 'candidate-pair': + { + if (!group.selected) + break; + + selectedCandidatePair = group; + + break; + } + + case 'local-candidate': + { + localCandidates[group.id] = group; + + break; + } + + case 'remote-candidate': + { + remoteCandidates[group.id] = group; + + break; + } + + case 'outbound-rtp': + { + if (group.isRemote) + break; + + let block; + + switch (group.mediaType) + { + case 'audio': + block = audio; + break; + case 'video': + block = video; + break; + } + + if (!block) + break; + + block['ssrc'] = group.ssrc; + block['bytes sent'] = group.bytesSent; + block['packets sent'] = group.packetsSent; + + if (block === video) + { + block['bitrate'] = + Math.round(group.bitrateMean / 1000) + ' kbps'; + block['frames encoded'] = group.framesEncoded; + video['frame rate'] = Math.round(group.framerateMean); + } + + block['NACK count'] = group.nackCount; + block['PLI count'] = group.pliCount; + block['FIR count'] = group.firCount; + + break; + } + } + } + + // Post checks. + + if (!video.ssrc) + video = {}; + + if (!audio.ssrc) + audio = {}; + + if (selectedCandidatePair) + { + let localCandidate = localCandidates[selectedCandidatePair.localCandidateId]; + let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId]; + + transport['protocol'] = localCandidate.transport; + transport['local IP'] = localCandidate.ipAddress; + transport['local port'] = localCandidate.portNumber; + transport['remote IP'] = remoteCandidate.ipAddress; + transport['remote port'] = remoteCandidate.portNumber; + } + + // Set state. + this.setState( + { + stats : + { + transport, + audio, + video + } + }); + } +} + +Stats.propTypes = +{ + stats : React.PropTypes.object.isRequired, + onClose : React.PropTypes.func.isRequired +}; diff --git a/app/lib/components/TransitionAppear.jsx b/app/lib/components/TransitionAppear.jsx new file mode 100644 index 0000000..d30d14d --- /dev/null +++ b/app/lib/components/TransitionAppear.jsx @@ -0,0 +1,59 @@ +'use strict'; + +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +const DEFAULT_DURATION = 1000; + +export default class TransitionAppear extends React.Component +{ + constructor(props) + { + super(props); + } + + render() + { + let props = this.props; + let duration = props.hasOwnProperty('duration') ? props.duration : DEFAULT_DURATION; + + return ( + + {this.props.children} + + ); + } +} + +TransitionAppear.propTypes = +{ + children : React.PropTypes.any, + duration : React.PropTypes.number +}; + +class FakeTransitionWrapper extends React.Component +{ + constructor(props) + { + super(props); + } + + render() + { + let children = React.Children.toArray(this.props.children); + + return children[0] || null; + } +} + +FakeTransitionWrapper.propTypes = +{ + children : React.PropTypes.any +}; diff --git a/app/lib/components/Video.jsx b/app/lib/components/Video.jsx new file mode 100644 index 0000000..965a9ba --- /dev/null +++ b/app/lib/components/Video.jsx @@ -0,0 +1,233 @@ +'use strict'; + +import React from 'react'; +import Logger from '../Logger'; +import classnames from 'classnames'; +import hark from 'hark'; + +const logger = new Logger('Video'); // eslint-disable-line no-unused-vars + +export default class Video extends React.Component +{ + constructor(props) + { + super(props); + + this.state = + { + width : 0, + height : 0, + resolution : null, + volume : 0 // Integer from 0 to 10. + }; + + let stream = props.stream; + + // Clean stream. + // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 + this._cleanStream(stream); + + // Current MediaStreamTracks info. + this._tracksHash = this._getTracksHash(stream); + + // Periodic timer to show video dimensions. + this._videoResolutionTimer = null; + + // Hark instance. + this._hark = null; + } + + render() + { + let props = this.props; + let state = this.state; + + return ( +
+ {state.width ? + ( +
+

{state.width}x{state.height}

+ {props.resolution ? +

{props.resolution}

+ :null} +
+ ) + :null} +
+
+
+ +
+ ); + } + + componentDidMount() + { + let stream = this.props.stream; + let video = this.refs.video; + + video.srcObject = stream; + + this._showVideoResolution(); + this._videoResolutionTimer = setInterval(() => + { + this._showVideoResolution(); + }, 500); + + if (stream.getAudioTracks().length > 0) + { + this._hark = hark(stream); + + this._hark.on('speaking', () => + { + logger.debug('hark "speaking" event'); + }); + + this._hark.on('stopped_speaking', () => + { + logger.debug('hark "stopped_speaking" event'); + + this.setState({ volume: 0 }); + }); + + this._hark.on('volume_change', (volume, threshold) => + { + if (volume < threshold) + return; + + // logger.debug('hark "volume_change" event [volume:%sdB, threshold:%sdB]', volume, threshold); + + this.setState( + { + volume : Math.round((volume - threshold) * (-10) / threshold) + }); + }); + } + } + + componentWillUnmount() + { + clearInterval(this._videoResolutionTimer); + + if (this._hark) + this._hark.stop(); + } + + componentWillReceiveProps(nextProps) + { + let stream = nextProps.stream; + + // Clean stream. + // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 + this._cleanStream(stream); + + // If there is something different in the stream, re-render it. + + let previousTracksHash = this._tracksHash; + + this._tracksHash = this._getTracksHash(stream); + + if (this._tracksHash !== previousTracksHash) + this.refs.video.srcObject = stream; + } + + handleResolutionClick() + { + if (!this.props.resolution) + return; + + logger.debug('handleResolutionClick()'); + + this.props.onResolutionChange(); + } + + _getTracksHash(stream) + { + return stream.getTracks() + .map((track) => + { + return track.id; + }) + .join('|'); + } + + _showVideoResolution() + { + let video = this.refs.video; + + this.setState( + { + width : video.videoWidth, + height : video.videoHeight + }); + } + + _cleanStream(stream) + { + // Hack for Firefox bug: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1347578 + + if (!stream) + return; + + let tracks = stream.getTracks(); + let previousNumTracks = tracks.length; + + // Remove ended tracks. + for (let track of tracks) + { + if (track.readyState === 'ended') + { + logger.warn('_cleanStream() | removing ended track [track:%o]', track); + + stream.removeTrack(track); + } + } + + // If there are multiple live audio tracks (related to the bug?) just keep + // the last one. + while (stream.getAudioTracks().length > 1) + { + let track = stream.getAudioTracks()[0]; + + logger.warn('_cleanStream() | removing live audio track due the presence of others [track:%o]', track); + + stream.removeTrack(track); + } + + // If there are multiple live video tracks (related to the bug?) just keep + // the last one. + while (stream.getVideoTracks().length > 1) + { + let track = stream.getVideoTracks()[0]; + + logger.warn('_cleanStream() | removing live video track due the presence of others [track:%o]', track); + + stream.removeTrack(track); + } + + let numTracks = stream.getTracks().length; + + if (numTracks !== previousNumTracks) + logger.warn('_cleanStream() | num tracks changed from %s to %s', previousNumTracks, numTracks); + } +} + +Video.propTypes = +{ + stream : React.PropTypes.object.isRequired, + resolution : React.PropTypes.string, + muted : React.PropTypes.bool, + mirror : React.PropTypes.bool, + onResolutionChange : React.PropTypes.func +}; diff --git a/app/lib/components/muiTheme.js b/app/lib/components/muiTheme.js new file mode 100644 index 0000000..d8d8205 --- /dev/null +++ b/app/lib/components/muiTheme.js @@ -0,0 +1,16 @@ +'use strict'; + +import getMuiTheme from 'material-ui/styles/getMuiTheme'; +import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme'; +import { grey500 } from 'material-ui/styles/colors'; + +// NOTE: I should clone it +let theme = lightBaseTheme; + +theme.palette.borderColor = grey500; + +let muiTheme = getMuiTheme(lightBaseTheme); + +export default muiTheme; + + diff --git a/app/lib/index.jsx b/app/lib/index.jsx new file mode 100644 index 0000000..c4edd15 --- /dev/null +++ b/app/lib/index.jsx @@ -0,0 +1,56 @@ +'use strict'; + +import browser from 'bowser'; +import webrtc from 'webrtc-adapter'; // eslint-disable-line no-unused-vars +import domready from 'domready'; +import UrlParse from 'url-parse'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import injectTapEventPlugin from 'react-tap-event-plugin'; +import randomString from 'random-string'; +import Logger from './Logger'; +import utils from './utils'; +import App from './components/App'; + +const REGEXP_FRAGMENT_ROOM_ID = new RegExp('^#room-id=([0-9a-zA-Z]+)$'); +const logger = new Logger(); + +logger.debug('detected browser [name:"%s", version:%s]', browser.name, browser.version); + +injectTapEventPlugin(); + +domready(() => +{ + logger.debug('DOM ready'); + + // Load stuff and run + utils.initialize() + .then(run) + .catch((error) => + { + console.error(error); + }); +}); + +function run() +{ + logger.debug('run() [environment:%s]', process.env.NODE_ENV); + + let container = document.getElementById('mediasoup-demo-app-container'); + let urlParser = new UrlParse(window.location.href, true); + let match = urlParser.hash.match(REGEXP_FRAGMENT_ROOM_ID); + let peerId = randomString({ length: 8 }).toLowerCase(); + let roomId; + + if (match) + { + roomId = match[1]; + } + else + { + roomId = randomString({ length: 8 }).toLowerCase(); + window.location = `#room-id=${roomId}`; + } + + ReactDOM.render(, container); +} diff --git a/app/lib/urlFactory.js b/app/lib/urlFactory.js new file mode 100644 index 0000000..818fc33 --- /dev/null +++ b/app/lib/urlFactory.js @@ -0,0 +1,15 @@ +'use strict'; + +const config = require('../config'); + +module.exports = +{ + getProtooUrl(peerId, roomId) + { + let hostname = window.location.hostname; + let port = config.protoo.listenPort; + let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`; + + return url; + } +}; diff --git a/app/lib/utils.js b/app/lib/utils.js new file mode 100644 index 0000000..3b92897 --- /dev/null +++ b/app/lib/utils.js @@ -0,0 +1,52 @@ +'use strict'; + +import browser from 'bowser'; +import Logger from './Logger'; + +const logger = new Logger('utils'); + +let mediaQueryDetectorElem; + +module.exports = +{ + initialize() + { + logger.debug('initialize()'); + + // Media query detector stuff + mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector'); + + return Promise.resolve(); + }, + + isDesktop() + { + return !!mediaQueryDetectorElem.offsetParent; + }, + + isMobile() + { + return !mediaQueryDetectorElem.offsetParent; + }, + + isPlanB() + { + if (browser.chrome || browser.chromium || browser.opera) + return true; + else + return false; + }, + + closeMediaStream(stream) + { + if (!stream) + return; + + let tracks = stream.getTracks(); + + for (let i=0, len=tracks.length; i < len; i++) + { + tracks[i].stop(); + } + } +}; diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..fafcb0f --- /dev/null +++ b/app/package.json @@ -0,0 +1,60 @@ +{ + "name": "mediasoup-demo-app", + "version": "1.0.0", + "private": true, + "description": "mediasoup demo app", + "author": "Iñaki Baz Castillo ", + "license": "All Rights Reserved", + "main": "lib/index.jsx", + "dependencies": { + "babel-runtime": "^6.23.0", + "bowser": "^1.6.1", + "classnames": "^2.2.5", + "debug": "^2.6.4", + "domready": "^1.0.8", + "hark": "ibc/hark#main-with-raf", + "material-ui": "^0.17.4", + "protoo-client": "^1.1.4", + "random-string": "^0.2.0", + "react": "^15.5.4", + "react-addons-css-transition-group": "^15.5.2", + "react-clipboard.js": "^1.0.1", + "react-dom": "^15.5.4", + "react-notification-system": "ibc/react-notification-system#master", + "react-tap-event-plugin": "^2.0.1", + "sdp-transform": "^2.3.0", + "url-parse": "^1.1.8", + "webrtc-adapter": "^3.3.3" + }, + "devDependencies": { + "babel-plugin-transform-object-assign": "^6.22.0", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "babelify": "^7.3.0", + "browser-sync": "^2.18.8", + "browserify": "^14.3.0", + "del": "^2.2.2", + "envify": "^4.0.0", + "eslint": "^3.19.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-react": "^6.10.3", + "gulp": "git://github.com/gulpjs/gulp.git#4.0", + "gulp-css-base64": "^1.3.4", + "gulp-eslint": "^3.0.1", + "gulp-header": "^1.8.8", + "gulp-if": "^2.0.2", + "gulp-plumber": "^1.1.0", + "gulp-rename": "^1.2.2", + "gulp-stylus": "^2.6.0", + "gulp-touch": "^1.0.1", + "gulp-uglify": "^2.1.2", + "gulp-util": "^3.0.8", + "mkdirp": "^0.5.1", + "ncp": "^2.0.0", + "nib": "^1.1.2", + "vinyl-buffer": "^1.0.0", + "vinyl-source-stream": "^1.1.0", + "watchify": "^3.9.0" + } +} diff --git a/app/resources/fonts/Roboto-light-ext.woff2 b/app/resources/fonts/Roboto-light-ext.woff2 new file mode 100644 index 0000000..feec9f1 Binary files /dev/null and b/app/resources/fonts/Roboto-light-ext.woff2 differ diff --git a/app/resources/fonts/Roboto-light.ttf b/app/resources/fonts/Roboto-light.ttf new file mode 100755 index 0000000..664e1b2 Binary files /dev/null and b/app/resources/fonts/Roboto-light.ttf differ diff --git a/app/resources/fonts/Roboto-light.woff2 b/app/resources/fonts/Roboto-light.woff2 new file mode 100644 index 0000000..4411cbc Binary files /dev/null and b/app/resources/fonts/Roboto-light.woff2 differ diff --git a/app/resources/fonts/Roboto-medium-ext.woff2 b/app/resources/fonts/Roboto-medium-ext.woff2 new file mode 100644 index 0000000..2b65545 Binary files /dev/null and b/app/resources/fonts/Roboto-medium-ext.woff2 differ diff --git a/app/resources/fonts/Roboto-medium.ttf b/app/resources/fonts/Roboto-medium.ttf new file mode 100755 index 0000000..aa00de0 Binary files /dev/null and b/app/resources/fonts/Roboto-medium.ttf differ diff --git a/app/resources/fonts/Roboto-medium.woff2 b/app/resources/fonts/Roboto-medium.woff2 new file mode 100644 index 0000000..6be92c7 Binary files /dev/null and b/app/resources/fonts/Roboto-medium.woff2 differ diff --git a/app/resources/fonts/Roboto-regular-ext.woff2 b/app/resources/fonts/Roboto-regular-ext.woff2 new file mode 100644 index 0000000..38d167d Binary files /dev/null and b/app/resources/fonts/Roboto-regular-ext.woff2 differ diff --git a/app/resources/fonts/Roboto-regular.ttf b/app/resources/fonts/Roboto-regular.ttf new file mode 100755 index 0000000..3e6e2e7 Binary files /dev/null and b/app/resources/fonts/Roboto-regular.ttf differ diff --git a/app/resources/fonts/Roboto-regular.woff2 b/app/resources/fonts/Roboto-regular.woff2 new file mode 100644 index 0000000..d1035f9 Binary files /dev/null and b/app/resources/fonts/Roboto-regular.woff2 differ diff --git a/app/resources/images/body-bg-2.jpg b/app/resources/images/body-bg-2.jpg new file mode 100644 index 0000000..10e59d1 Binary files /dev/null and b/app/resources/images/body-bg-2.jpg differ diff --git a/app/resources/images/body-bg.jpg b/app/resources/images/body-bg.jpg new file mode 100644 index 0000000..299edb4 Binary files /dev/null and b/app/resources/images/body-bg.jpg differ diff --git a/app/resources/images/buddy.svg b/app/resources/images/buddy.svg new file mode 100644 index 0000000..45d5ca5 --- /dev/null +++ b/app/resources/images/buddy.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/app/resources/images/close.svg b/app/resources/images/close.svg new file mode 100644 index 0000000..5704800 --- /dev/null +++ b/app/resources/images/close.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/resources/images/room.svg b/app/resources/images/room.svg new file mode 100644 index 0000000..78eacb9 --- /dev/null +++ b/app/resources/images/room.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/resources/images/stats.svg b/app/resources/images/stats.svg new file mode 100644 index 0000000..857da89 --- /dev/null +++ b/app/resources/images/stats.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/resources/js/antiglobal.js b/app/resources/js/antiglobal.js new file mode 100644 index 0000000..5fe0080 --- /dev/null +++ b/app/resources/js/antiglobal.js @@ -0,0 +1 @@ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var n;n="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,n.antiglobal=e()}}(function(){return function e(n,o,t){function r(i,u){if(!o[i]){if(!n[i]){var l="function"==typeof require&&require;if(!u&&l)return l(i,!0);if(f)return f(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var d=o[i]={exports:{}};n[i][0].call(d.exports,function(e){var o=n[i][1][e];return r(o?o:e)},d,d.exports,e,n,o,t)}return o[i].exports}for(var f="function"==typeof require&&require,i=0;i .controls { + pointer-events: none; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + transition-property: opacity; + transition-duration: 0.25s; + + > .control { + pointer-events: auto; + flex: 0 0 auto; + margin: 4px !important; + margin-left: 0 !important; + height: 32px !important; + width: 32px !important; + padding: 0 !important; + background-color: rgba(#000, 0.25) !important; + border-radius: 100%; + opacity: 0.8; + + &:hover { + background-color: rgba(#000, 0.85) !important; + opacity: 1; + } + } + } + + > .info { + position: absolute; + z-index: 10; + bottom: 4px; + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + > .peer-id { + padding: 4px 14px; + font-size: 14px; + color: rgba(#fff, 0.75); + background: rgba(#000, 0.6); + border-radius: 4px; + } + } +} + +@keyframes LocalVideo-state-checking { + 50% { + border-color: rgba(orange, 0.9); + } +} diff --git a/app/stylus/components/RemoteVideo.styl b/app/stylus/components/RemoteVideo.styl new file mode 100644 index 0000000..4ab512f --- /dev/null +++ b/app/stylus/components/RemoteVideo.styl @@ -0,0 +1,110 @@ +[data-component='RemoteVideo'] { + position: relative; + flex: 0 0 auto; + transition-duration: 0.25s; + + TransitionAppear(500ms); + + &.fullsize { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: 0; + height: 100vh; + width: 100%; + + > .info { + justify-content: flex-end; + right: 4px; + } + } + + +desktop() { + height: 350px; + width: 400px; + margin: 4px; + border: 4px solid rgba(#fff, 0.2); + + &.fullsize { + border-width: 0; + } + } + + +mobile() { + height: 50vh; + width: 100%; + border: 4px solid rgba(#fff, 0.2); + border-bottom-width: 0; + + &:last-child { + border-bottom-width: 4px; + } + + &.fullsize { + border-width: 0; + } + } + + > .controls { + pointer-events: none; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + transition-property: opacity; + transition-duration: 0.25s; + opacity: 0.8; + + > .control { + pointer-events: auto; + flex: 0 0 auto; + margin: 4px !important; + margin-left: 0 !important; + height: 32px !important; + width: 32px !important; + padding: 0 !important; + background-color: rgba(#000, 0.25) !important; + border-radius: 100%; + opacity: 0.8; + + &:hover { + background-color: rgba(#000, 0.85) !important; + opacity: 1; + } + } + } + + > .info { + position: absolute; + z-index: 10; + bottom: 4px; + left: 0; + right: 0; + display: flex; + flex-direction: row; + + +desktop() { + justify-content: center; + } + + +mobile() { + justify-content: flex-end; + right: 4px; + } + + > .peer-id { + padding: 6px 16px; + font-size: 16px; + color: rgba(#fff, 0.75); + background: rgba(#000, 0.6); + border-radius: 4px; + } + } +} diff --git a/app/stylus/components/Room.styl b/app/stylus/components/Room.styl new file mode 100644 index 0000000..22cafd2 --- /dev/null +++ b/app/stylus/components/Room.styl @@ -0,0 +1,114 @@ +[data-component='Room'] { + position: relative; + overflow: auto; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + +desktop() { + min-height: 100vh; + width: 100%; + } + + > .room-link-wrapper { + pointer-events: none; + position: absolute; + z-index: 1; + top: 0; + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: center; + + > .room-link { + width: auto; + background-color: rgba(#fff, 0.8); + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + box-shadow: 0px 3px 12px 2px rgba(#111, 0.4); + + > a.link { + display: block;; + user-select: none; + pointer-events: auto; + padding: 10px 20px; + color: #104758; + font-size: 16px; + cursor: pointer; + text-decoration: none; + transition-property: opacity; + transition-duration: 0.25s; + opacity: 0.8; + + &:hover { + opacity: 1; + text-decoration: underline; + } + } + } + } + + > .remote-videos { + +desktop() { + min-height: 100vh; + width: 100%; + padding-bottom: 150px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: center; + align-content: center; + } + + +mobile() { + min-height: 100vh; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + } + + > .local-video { + position: fixed; + display: flex; + flex-direction: row; + z-index: 100; + box-shadow: 0px 5px 12px 2px rgba(#111, 0.5); + + +desktop() { + bottom: 20px; + left: 20px; + } + + +mobile() { + bottom: 10px; + left: 10px; + } + + > .show-stats { + position: absolute; + bottom: 5px; + right: -40px; + width: 30px; + height: 30px; + background-image: url('/resources/images/stats.svg'); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + background-color: rgba(#000, 0.25); + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition-duration: 0.25s; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/app/stylus/components/Stats.styl b/app/stylus/components/Stats.styl new file mode 100644 index 0000000..f715e8f --- /dev/null +++ b/app/stylus/components/Stats.styl @@ -0,0 +1,114 @@ +[data-component='Stats'] { + TransitionAppear(500ms); + + +desktop() { + position: relative; + display: flex; + flex-direction: row; + padding: 10px 0; + min-width: 100px; + background-color: rgba(#1c446f, 0.75); + font-size: 0.7rem; + } + + +mobile() { + position: fixed; + z-index: 3000; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(#1c446f, 0.75); + padding: 40px 10px; + font-size: 0.9rem; + overflow: auto; + } + + > .close { + background-image: url('/resources/images/close.svg'); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + cursor: pointer; + opacity: 0.75; + transition-duration: 0.25s; + + +desktop() { + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + } + + +mobile() { + position: fixed; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + } + + &:hover { + opacity: 1; + } + } + + > .block { + padding: 5px; + + +desktop() { + border-right: 1px solid rgba(#fff, 0.15); + + &:last-child { + border-right: none; + } + } + + +mobile() { + width: 100%; + } + + > h1 { + margin-bottom: 8px; + text-align: center; + font-weight: 400; + color: #fff; + } + + > .item { + display: flex; + flex-direction: row; + margin: 3px 0; + font-size: 0.95em; + font-weight: 300; + + > .key { + text-align: right; + color: rgba(#fff, 0.9); + + +desktop() { + width: 85px; + } + + +mobile() { + width: 50%; + } + } + + > .value { + margin-left: 10px; + text-align: left; + color: #aae22b; + + +desktop() { + width: 85px; + } + + +mobile() { + width: 50%; + } + } + } + } +} diff --git a/app/stylus/components/Video.styl b/app/stylus/components/Video.styl new file mode 100644 index 0000000..91cfbda --- /dev/null +++ b/app/stylus/components/Video.styl @@ -0,0 +1,79 @@ +[data-component='Video'] { + position: relative; + height: 100%; + width: 100%; + overflow: hidden; + + > .resolution { + position: absolute; + z-index: 5; + top: 0; + left: 0; + padding: 4px 8px; + border-bottom-right-radius: 5px; + background: rgba(#000, 0.5); + transition-property: background; + transition-duration: 0.25s; + + &.clickable { + cursor: pointer; + + &:hover { + background: rgba(#000, 0.85); + } + } + + > p { + font-size: 11px; + color: rgba(#fff, 0.75); + } + } + + > .volume { + position: absolute; + z-index: 5; + top: 0 + bottom: 0; + right: 0; + width: 10px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + > .bar { + width: 6px; + border-radius: 6px; + background: rgba(yellow, 0.65); + transition-property: height background-color; + transition-duration: 0.25s; + + &.level0 { height: 0; background-color: rgba(yellow, 0.65); } + &.level1 { height: 10%; background-color: rgba(yellow, 0.65); } + &.level2 { height: 20%; background-color: rgba(yellow, 0.65); } + &.level3 { height: 30%; background-color: rgba(yellow, 0.65); } + &.level4 { height: 40%; background-color: rgba(orange, 0.65); } + &.level5 { height: 50%; background-color: rgba(orange, 0.65); } + &.level6 { height: 60%; background-color: rgba(red, 0.65); } + &.level7 { height: 70%; background-color: rgba(red, 0.65); } + &.level8 { height: 80%; background-color: rgba(#000, 0.65); } + &.level9 { height: 90%; background-color: rgba(#000, 0.65); } + &.level10 { height: 100%; background-color: rgba(#000, 0.65); } + } + } + + > video { + height: 100%; + width: 100%; + object-fit: cover; + background-color: rgba(#041918, 0.65); + background-image: url('/resources/images/buddy.svg'); + background-position: bottom; + background-size: auto 85%; + background-repeat: no-repeat; + + &.mirror { + transform: scaleX(-1); + } + } +} diff --git a/app/stylus/fonts.styl b/app/stylus/fonts.styl new file mode 100644 index 0000000..65b215d --- /dev/null +++ b/app/stylus/fonts.styl @@ -0,0 +1,62 @@ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: + local('Roboto Light'), + local('Roboto-Light'), url('/resources/fonts/Roboto-light-ext.woff2') format('woff2'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: + local('Roboto Light'), + local('Roboto-Light'), + url('/resources/fonts/Roboto-light.woff2') format('woff2'), + url('/resources/fonts/Roboto-light.ttf') format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: + local('Roboto'), + local('Roboto-Regular'), + url('/resources/fonts/Roboto-regular-ext.woff2') format('woff2'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: + local('Roboto'), + local('Roboto-Regular'), + url('/resources/fonts/Roboto-regular.woff2') format('woff2'), + url('/resources/fonts/Roboto-regular.ttf') format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: + local('Roboto Medium'), + local('Roboto-Medium'), + url('/resources/fonts/Roboto-medium-ext.woff2') format('woff2'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: + local('Roboto Medium'), + local('Roboto-Medium'), + url('/resources/fonts/Roboto-medium.woff2') format('woff2'), + url('/resources/fonts/Roboto-medium.ttf') format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} diff --git a/app/stylus/index.styl b/app/stylus/index.styl new file mode 100644 index 0000000..8ad2413 --- /dev/null +++ b/app/stylus/index.styl @@ -0,0 +1,67 @@ +@import 'nib'; + +global-reset(); + +@import './mixins'; +@import './fonts'; + +html { + font-family: 'Roboto'; + background-image: url('/resources/images/body-bg-2.jpg'); + background-attachment: fixed; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + min-height: 100vh; + width: 100%; + font-weight: 400; + + +desktop() { + font-size: 16px; + } + + +mobile() { + font-size: 12px; + } +} + +body { + background: none; +} + +* { + box-sizing: border-box; + outline: none; +} + +#mediasoup-demo-app-container { + min-height: 100vh; + width: 100%; + + // Components + @import './components/App'; + @import './components/Room'; + @import './components/LocalVideo'; + @import './components/RemoteVideo'; + @import './components/Video'; + @import './components/Stats'; +} + +// Hack to detect in JS the current media query +#mediasoup-demo-app-media-query-detector { + position: relative; + z-index: -1000; + bottom: 0; + left: 0; + height: 1px; + width: 1px; + + // In desktop let it "visible" so elem.offsetParent returns the parent element + +desktop() {} + + // In mobile ensure it's not displayed so elem.offsetParent returns null + +mobile() { + display: none; + position: fixed; // Required for old IE + } +} diff --git a/app/stylus/mixins.styl b/app/stylus/mixins.styl new file mode 100644 index 0000000..191ebbc --- /dev/null +++ b/app/stylus/mixins.styl @@ -0,0 +1,33 @@ +placeholder() + &::-webkit-input-placeholder + {block} + &:-moz-placeholder + {block} + &::-moz-placeholder + {block} + &:-ms-input-placeholder + {block} + +text-fill-color() + -webkit-text-fill-color: arguments; + -moz-text-fill-color: arguments; + text-fill-color: arguments; + +mobile() + @media (max-device-width: 720px) + {block} + +desktop() + @media (min-device-width: 721px) + {block} + +TransitionAppear($duration = 1s, $appearOpacity = 0, $activeOpacity = 1) + will-change: opacity; + + &.transition-appear + opacity: $appearOpacity; + + &.transition-appear.transition-appear-active + transition-property: opacity; + transition-duration: $duration; + opacity: $activeOpacity; diff --git a/server/certs/mediasoup-demo.localhost.cert.pem b/server/certs/mediasoup-demo.localhost.cert.pem new file mode 100644 index 0000000..0d62758 --- /dev/null +++ b/server/certs/mediasoup-demo.localhost.cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTTCCAjWgAwIBAgIEWPyBpzANBgkqhkiG9w0BAQUFADBQMSEwHwYDVQQDDBht +ZWRpYXNvdXAtZGVtby5sb2NhbGhvc3QxFzAVBgNVBAoMDm1lZGlhc291cC1kZW1v +MRIwEAYDVQQLDAltZWRpYXNvdXAwHhcNMTcwNDIyMTAyNzU1WhcNMzcwNDE4MTAy +NzU1WjBQMSEwHwYDVQQDDBhtZWRpYXNvdXAtZGVtby5sb2NhbGhvc3QxFzAVBgNV +BAoMDm1lZGlhc291cC1kZW1vMRIwEAYDVQQLDAltZWRpYXNvdXAwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCynyO1szmonG3dk+SSQflM5DqzBNTI8ufA +9Za8ltCmq211y6N9cjhKexHS/Eiu8x907QFNgzcagVgrPAA7XJJdckoupKsf4Qrk +wWrpW7s/nJV2H04oIShAdWWbVckRhMLzdz+VWV0rM4AtjBxYu89B3OH9C1p4uYGH +3i4/E147gmk+NaYdddUhYbKYTBhjtjrC2IN/lHT+VfGX8yJ0q0J9Pv6B+17pYJ1P +QAyGhgzmvvi500t1Ke42EI7QOYAGzOw7S/zNl7lBVmXdQGmpGipD7sMVg56txNmt +7RRETaaQ5uNpCxkBcJdIX/DzGV9xNKFoMLm1GUEdTY1RnM7jN0HNAgMBAAGjLzAt +MAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFDazfBdo/7PNIfarcfMY6txrxQgoMA0G +CSqGSIb3DQEBBQUAA4IBAQCtqv4Wsnp658HxYyDBxX6CnFpnNfMDqeE8scFeihmX +X3AtJwWMWZJpOX26eOlVqee1h3QyTmvnITau+1Sphttt6EYoHBBHC5It4sCV/kwm +6iiKKah0uxlXUyoj0ylRMwBA16b922OXm8ozDzo3FQWASLstYaUQf1kJtLQimGrH +a4YYiQtRkCO7NvGjaHS8zwmkUdOy8mE1sXol8CiiwCJPGF5vUQMQzj1zqOhQEPLM +44XCmM1CawTfFLhwmgZpPPzYCDMfEz1tF5M/ODOtSTytGoa0H2q4YpXVCiftAQV5 +fpSOlyqYaVk7oBkrHS6I6n58MATfuKcPn5YMJ8S/64u1 +-----END CERTIFICATE----- diff --git a/server/certs/mediasoup-demo.localhost.key.pem b/server/certs/mediasoup-demo.localhost.key.pem new file mode 100644 index 0000000..1584020 --- /dev/null +++ b/server/certs/mediasoup-demo.localhost.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsp8jtbM5qJxt3ZPkkkH5TOQ6swTUyPLnwPWWvJbQpqttdcuj +fXI4SnsR0vxIrvMfdO0BTYM3GoFYKzwAO1ySXXJKLqSrH+EK5MFq6Vu7P5yVdh9O +KCEoQHVlm1XJEYTC83c/lVldKzOALYwcWLvPQdzh/QtaeLmBh94uPxNeO4JpPjWm +HXXVIWGymEwYY7Y6wtiDf5R0/lXxl/MidKtCfT7+gfte6WCdT0AMhoYM5r74udNL +dSnuNhCO0DmABszsO0v8zZe5QVZl3UBpqRoqQ+7DFYOercTZre0URE2mkObjaQsZ +AXCXSF/w8xlfcTShaDC5tRlBHU2NUZzO4zdBzQIDAQABAoIBABLK2WfxbjyGEK0C +NUcJ99+WF3LkLDrkC2vqqqw2tccDPCXrgczd6nwzjIGFF2SIoaOcl8l+55o7R3ps ++p1ENQXt004q9vIIrCu7CbN5ei7MG5Fs470nF+QINeNs2BWmwRf6UM82sq2r4m1o +U0cmozyLr57+xcrzwWP5BSaPtBdQiPjzML7E4PIg9WHbUhYjtgc90a0ruHhp9rlR +QbFsxX9KMcTeJN+pQA+dJWlJrP4EuQurIyupl2zx+XLBzb9j4pL9wlklu3IFI6v+ +k+FVTVKXqjndanCbeOvPTli01ILng5UNUEsleWbuvFLuiXlSkduhDVBWECmNxJIR +VCP46EECgYEA6HYNBSyb1NtxXx0Pv2Itg4TbRSP1vQOoQjJzBjrE6FYQJf6lOgMn +sQJFGmZMXiKcyG729Ntw3gvFjnk25V4DNsK1mbLy9cBO8ETMpm2NfAt9QnH40rmp +nd9cgu6v3AFvlBwNJAGsGRFDgshExeQlY28aQy5FevsHE/wc3UpDfRECgYEAxLVw +ocJX/PfvhwW8S0neGtJ4n8h3MLczOxAbHulG44aTwijSIBRxS1E0h+w0jG8Lwr/O +518RpevKhcoGQtf0XuRu1TP2UAtF/rSflCg8a/zHUBen5N2loOWc7Pd9S71klDoi +en7d1NIUZq4Cljb1D1UYW9Ek6wQ9tQFe5EtaKP0CgYAB+N5raNF5oNL5Z5m2mfKg +5wOlNoTjMaC/zwXCy8TX48MHT32/XD999PL5Il0Lf2etG6Pkt+fhOmBWsRiSIZYN +ZOF9iFMfWp5Q04SY9Nz6bG6HncfqocCaokZ6pePADhMQQpyp7Ym0PL1B4skSlLjs +ewjSARZ90JtixATKq9KewQKBgB1T294SJqItqQWdgkxLUBT5qkhQUAzwU3AL369F +Im+Lwf3hripgQd/z1HwraE5DxCIeDNAMKYpuVDyMOVC/98wqDKg23hNjCuWFsoEZ +WqDTCDhVvo9tyGLruPDPmVuweg1reXZ/8bzoMWh5qyMQQIsvqbkOvo1XjYeuE6K/ +5UpVAoGBAIvYtZi1a2UFhJmKNaa0dnOWhLAtWjsOh7+k/nM36Zgl1/W7veWD3yiA +HTbyFYK0Rq696OAlVemEVNVEm4bgS2reEJHWYwrQBc07iYVww+qWkocKUVfNmuvd +BUx/QnIKZAhFpDFYLoLUddnxOsLJd4CiuXeVEaLsLZ+eZcIlzWPc +-----END RSA PRIVATE KEY----- diff --git a/server/config.example.js b/server/config.example.js new file mode 100644 index 0000000..b989bb4 --- /dev/null +++ b/server/config.example.js @@ -0,0 +1,66 @@ +module.exports = +{ + // DEBUG env variable For the NPM debug module. + debug : '*LOG* *WARN* *ERROR* *mediasoup-worker*', + // Listening hostname for `gulp live|open`. + domain : 'localhost', + tls : + { + cert : `${__dirname}/certs/mediasoup-demo.localhost.cert.pem`, + key : `${__dirname}/certs/mediasoup-demo.localhost.key.pem` + }, + protoo : + { + listenIp : '0.0.0.0', + listenPort : 3443 + }, + mediasoup : + { + // mediasoup Server settings. + logLevel : 'debug', + logTags : + [ + 'info', + // 'ice', + // 'dlts', + 'rtp', + // 'srtp', + 'rtcp', + // 'rbe', + 'rtx' + ], + rtcIPv4 : true, + rtcIPv6 : true, + rtcAnnouncedIPv4 : null, + rtcAnnouncedIPv6 : null, + rtcMinPort : 40000, + rtcMaxPort : 49999, + // mediasoup Room settings. + roomCodecs : + [ + { + kind : 'audio', + name : 'audio/opus', + clockRate : 48000, + parameters : + { + useInbandFec : 1, + minptime : 10 + } + }, + { + kind : 'video', + name : 'video/vp8', + clockRate : 90000 + } + ], + // mediasoup per Peer Transport settings. + peerTransport : + { + udp : true, + tcp : true + }, + // mediasoup per Peer max sending bitrate (in kpbs). + maxBitrate : 500000 + } +}; diff --git a/server/gulpfile.js b/server/gulpfile.js new file mode 100644 index 0000000..4557bfe --- /dev/null +++ b/server/gulpfile.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Tasks: + * + * gulp lint + * Checks source code + * + * gulp watch + * Observes changes in the code + * + * gulp + * Invokes both `lint` and `watch` tasks + */ + +const gulp = require('gulp'); +const plumber = require('gulp-plumber'); +const eslint = require('gulp-eslint'); + +gulp.task('lint', () => +{ + let src = + [ + 'gulpfile.js', + 'server.js', + 'config.example.js', + 'config.js', + 'lib/**/*.js' + ]; + + return gulp.src(src) + .pipe(plumber()) + .pipe(eslint( + { + extends : [ 'eslint:recommended' ], + parserOptions : + { + ecmaVersion : 6, + sourceType : 'module', + ecmaFeatures : + { + impliedStrict : true + } + }, + envs : + [ + 'es6', + 'node', + 'commonjs' + ], + 'rules' : + { + 'no-console' : 0, + 'no-undef' : 2, + 'no-unused-vars' : [ 2, { 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' ], + } + })) + .pipe(eslint.format()); +}); + +gulp.task('watch', (done) => +{ + let src = [ 'gulpfile.js', 'server.js', 'config.js', 'lib/**/*.js' ]; + + gulp.watch(src, gulp.series( + 'lint' + )); + + done(); +}); + +gulp.task('default', gulp.series('lint', 'watch')); diff --git a/server/lib/Room.js b/server/lib/Room.js new file mode 100644 index 0000000..a363ebe --- /dev/null +++ b/server/lib/Room.js @@ -0,0 +1,498 @@ +'use strict'; + +const EventEmitter = require('events').EventEmitter; +const protooServer = require('protoo-server'); +const webrtc = require('mediasoup').webrtc; +const logger = require('./logger')('Room'); +const config = require('../config'); + +const MAX_BITRATE = config.mediasoup.maxBitrate || 3000000; +const MIN_BITRATE = Math.min(50000 || MAX_BITRATE); +const BITRATE_FACTOR = 0.75; + +class Room extends EventEmitter +{ + constructor(roomId, mediaServer) + { + logger.log('constructor() [roomId:"%s"]', roomId); + + super(); + this.setMaxListeners(Infinity); + + // Room ID. + this._roomId = roomId; + // Protoo Room instance. + this._protooRoom = new protooServer.Room(); + // mediasoup Room instance. + this._mediaRoom = null; + // Pending peers (this is because at the time we get the first peer, the + // mediasoup room does not yet exist). + this._pendingProtooPeers = []; + // Current max bitrate for all the participants. + this._maxBitrate = MAX_BITRATE; + + // Create a mediasoup room. + mediaServer.createRoom( + { + mediaCodecs : config.mediasoup.roomCodecs + }) + .then((room) => + { + logger.debug('mediasoup room created'); + + this._mediaRoom = room; + + process.nextTick(() => + { + this._mediaRoom.on('newpeer', (peer) => + { + this._updateMaxBitrate(); + + peer.on('close', () => + { + this._updateMaxBitrate(); + }); + }); + }); + + // Run all the pending join requests. + for (let protooPeer of this._pendingProtooPeers) + { + this._handleProtooPeer(protooPeer); + } + }); + } + + get id() + { + return this._roomId; + } + + close() + { + logger.debug('close()'); + + // Close the protoo Room. + this._protooRoom.close(); + + // Close the mediasoup Room. + if (this._mediaRoom) + this._mediaRoom.close(); + + // Emit 'close' event. + this.emit('close'); + } + + logStatus() + { + if (!this._mediaRoom) + return; + + logger.log( + 'logStatus() [room id:"%s", protoo peers:%s, mediasoup peers:%s]', + this._roomId, + this._protooRoom.peers.length, + this._mediaRoom.peers.length); + } + + createProtooPeer(peerId, transport) + { + logger.log('createProtooPeer() [peerId:"%s"]', peerId); + + if (this._protooRoom.hasPeer(peerId)) + { + logger.warn('createProtooPeer() | there is already a peer with same peerId, closing the previous one [peerId:"%s"]', peerId); + + let protooPeer = this._protooRoom.getPeer(peerId); + + protooPeer.close(); + } + + return this._protooRoom.createPeer(peerId, transport) + .then((protooPeer) => + { + if (this._mediaRoom) + this._handleProtooPeer(protooPeer); + else + this._pendingProtooPeers.push(protooPeer); + }); + } + + _handleProtooPeer(protooPeer) + { + logger.debug('_handleProtooPeer() [peerId:"%s"]', protooPeer.id); + + let mediaPeer = this._mediaRoom.Peer(protooPeer.id); + let peerconnection; + + protooPeer.data.msids = []; + + protooPeer.on('close', () => + { + logger.debug('protoo Peer "close" event [peerId:"%s"]', protooPeer.id); + + this._protooRoom.spread( + 'removepeer', + { + peer : + { + id : protooPeer.id, + msids : protooPeer.data.msids + } + }); + + // Close the media stuff. + if (peerconnection) + peerconnection.close(); + else + mediaPeer.close(); + + // If this is the latest peer in the room, close the room. + // However, wait a bit (for reconnections). + setTimeout(() => + { + if (this._mediaRoom && this._mediaRoom.closed) + return; + + if (this._protooRoom.peers.length === 0) + { + logger.log( + 'last peer in the room left, closing the room [roomId:"%s"]', + this._roomId); + + this.close(); + } + }, 10000); + }); + + Promise.resolve() + // Send 'join' request to the new peer. + .then(() => + { + return protooPeer.send( + 'joinme', + { + peerId : protooPeer.id, + roomId : this.id + }); + }) + // Create a RTCPeerConnection instance and set media capabilities. + .then((data) => + { + peerconnection = new webrtc.RTCPeerConnection( + { + peer : mediaPeer, + usePlanB : !!data.usePlanB, + transportOptions : config.mediasoup.peerTransport, + maxBitrate : this._maxBitrate + }); + + // Store the RTCPeerConnection instance within the protoo Peer. + protooPeer.data.peerconnection = peerconnection; + + mediaPeer.on('newtransport', (transport) => + { + transport.on('iceselectedtuplechange', (data) => + { + logger.log('"iceselectedtuplechange" event [peerId:"%s", protocol:%s, remoteIP:%s, remotePort:%s]', + protooPeer.id, data.protocol, data.remoteIP, data.remotePort); + }); + }); + + // Set RTCPeerConnection capabilities. + return peerconnection.setCapabilities(data.capabilities); + }) + // Send 'peers' request for the new peer to know about the existing peers. + .then(() => + { + return protooPeer.send( + 'peers', + { + peers : this._protooRoom.peers + // Filter this protoo Peer. + .filter((peer) => + { + return peer !== protooPeer; + }) + .map((peer) => + { + return { + id : peer.id, + msids : peer.data.msids + }; + }) + }); + }) + // Tell all the other peers about the new peer. + .then(() => + { + this._protooRoom.spread( + 'addpeer', + { + peer : + { + id : protooPeer.id, + msids : protooPeer.data.msids + } + }, + [ protooPeer ]); + }) + .then(() => + { + // Send initial SDP offer. + return this._sendOffer(protooPeer, + { + offerToReceiveAudio : 1, + offerToReceiveVideo : 1 + }); + }) + .then(() => + { + // Handle PeerConnection events. + peerconnection.on('negotiationneeded', () => + { + logger.debug('"negotiationneeded" event [peerId:"%s"]', protooPeer.id); + + // Send SDP re-offer. + this._sendOffer(protooPeer); + }); + + peerconnection.on('signalingstatechange', () => + { + logger.debug('"signalingstatechange" event [peerId:"%s", signalingState:%s]', + protooPeer.id, peerconnection.signalingState); + }); + }) + .then(() => + { + protooPeer.on('request', (request, accept, reject) => + { + logger.debug('protoo Peer "request" event [method:%s]', request.method); + + switch(request.method) + { + case 'reofferme': + { + accept(); + this._sendOffer(protooPeer); + + break; + } + + case 'restartice': + { + peerconnection.restartIce() + .then(() => + { + accept(); + }) + .catch((error) => + { + logger.error('"restartice" request failed: %s', error); + logger.error('stack:\n' + error.stack); + + reject(500, `"restartice" failed: ${error.message}`); + }); + + break; + } + + case 'disableremotevideo': + { + let videoMsid = request.data.msid; + let disable = request.data.disable; + let videoRtpSender; + + for (let rtpSender of mediaPeer.rtpSenders) + { + if (rtpSender.kind !== 'video') + continue; + + let msid = rtpSender.rtpParameters.userParameters.msid.split(/\s/)[0]; + + if (msid === videoMsid) + { + videoRtpSender = rtpSender; + break; + } + } + + if (videoRtpSender) + { + return Promise.resolve() + .then(() => + { + if (disable) + return videoRtpSender.disable(); + else + return videoRtpSender.enable(); + }) + .then(() => + { + accept(); + }) + .catch((error) => + { + logger.error('"disableremotevideo" request failed: %s', error); + logger.error('stack:\n' + error.stack); + + reject(500, `"disableremotevideo" failed: ${error.message}`); + }); + } + else + { + reject(404, 'msid not found'); + } + + break; + } + + default: + { + logger.error('unknown method'); + + reject(404, 'unknown method'); + } + } + }); + }) + .catch((error) => + { + logger.error('_handleProtooPeer() failed: %s', error.message); + logger.error('stack:\n' + error.stack); + + protooPeer.close(); + }); + } + + _sendOffer(protooPeer, options) + { + logger.debug('_sendOffer() [peerId:"%s"]', protooPeer.id); + + let peerconnection = protooPeer.data.peerconnection; + let mediaPeer = peerconnection.peer; + + return Promise.resolve() + .then(() => + { + return peerconnection.createOffer(options); + }) + .then((desc) => + { + return peerconnection.setLocalDescription(desc); + }) + // Send the SDP offer to the peer. + .then(() => + { + return protooPeer.send( + 'offer', + { + offer : peerconnection.localDescription.serialize() + }); + }) + // Process the SDP answer from the peer. + .then((data) => + { + let answer = data.answer; + + return peerconnection.setRemoteDescription(answer); + }) + .then(() => + { + let oldMsids = protooPeer.data.msids; + + // Reset peer's msids. + protooPeer.data.msids = []; + + let setMsids = new Set(); + + // Update peer's msids information. + for (let rtpReceiver of mediaPeer.rtpReceivers) + { + let msid = rtpReceiver.rtpParameters.userParameters.msid.split(/\s/)[0]; + + setMsids.add(msid); + } + + protooPeer.data.msids = Array.from(setMsids); + + // If msids changed, notify. + let sameValues = ( + oldMsids.length == protooPeer.data.msids.length) && + oldMsids.every((element, index) => + { + return element === protooPeer.data.msids[index]; + }); + + if (!sameValues) + { + this._protooRoom.spread( + 'updatepeer', + { + peer : + { + id : protooPeer.id, + msids : protooPeer.data.msids + } + }, + [ protooPeer ]); + } + }) + .catch((error) => + { + logger.error('_sendOffer() failed: %s', error); + logger.error('stack:\n' + error.stack); + + logger.warn('resetting peerconnection'); + peerconnection.reset(); + }); + } + + _updateMaxBitrate() + { + if (this._mediaRoom.closed) + return; + + let numPeers = this._mediaRoom.peers.length; + let previousMaxBitrate = this._maxBitrate; + let newMaxBitrate; + + if (numPeers <= 2) + { + newMaxBitrate = MAX_BITRATE; + } + else + { + newMaxBitrate = Math.round(MAX_BITRATE / ((numPeers - 1) * BITRATE_FACTOR)); + + if (newMaxBitrate < MIN_BITRATE) + newMaxBitrate = MIN_BITRATE; + } + + if (newMaxBitrate === previousMaxBitrate) + return; + + for (let peer of this._mediaRoom.peers) + { + if (!peer.capabilities || peer.closed) + continue; + + for (let transport of peer.transports) + { + if (transport.closed) + continue; + + transport.setMaxBitrate(newMaxBitrate); + } + } + + logger.log('_updateMaxBitrate() [num peers:%s, before:%skbps, now:%skbps]', + numPeers, + Math.round(previousMaxBitrate / 1000), + Math.round(newMaxBitrate / 1000)); + + this._maxBitrate = newMaxBitrate; + } +} + +module.exports = Room; diff --git a/server/lib/logger.js b/server/lib/logger.js new file mode 100644 index 0000000..9b49576 --- /dev/null +++ b/server/lib/logger.js @@ -0,0 +1,56 @@ +'use strict'; + +const debug = require('debug'); + +const NAMESPACE = 'mediasoup-demo-server'; + +class Logger +{ + constructor(prefix) + { + if (prefix) + { + this._debug = debug(NAMESPACE + ':' + prefix); + this._log = debug(NAMESPACE + ':LOG:' + prefix); + this._warn = debug(NAMESPACE + ':WARN:' + prefix); + this._error = debug(NAMESPACE + ':ERROR:' + prefix); + } + else + { + this._debug = debug(NAMESPACE); + this._log = debug(NAMESPACE + ':LOG'); + this._warn = debug(NAMESPACE + ':WARN'); + this._error = debug(NAMESPACE + ':ERROR'); + } + + this._debug.log = console.info.bind(console); + this._log.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + } + + get debug() + { + return this._debug; + } + + get log() + { + return this._log; + } + + get warn() + { + return this._warn; + } + + get error() + { + return this._error; + } +} + +module.exports = function(prefix) +{ + return new Logger(prefix); +}; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..605dc9c --- /dev/null +++ b/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "mediasoup-demo-server", + "version": "1.0.0", + "private": true, + "description": "mediasoup demo server", + "author": "Iñaki Baz Castillo ", + "license": "All Rights Reserved", + "main": "lib/index.js", + "dependencies": { + "colors": "^1.1.2", + "debug": "^2.6.4", + "express": "^4.15.2", + "mediasoup": "^1.0.1", + "protoo-server": "^1.1.4" + }, + "devDependencies": { + "babel-plugin-transform-object-assign": "^6.22.0", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "gulp": "git://github.com/gulpjs/gulp.git#4.0", + "gulp-eslint": "^3.0.1", + "gulp-plumber": "^1.1.0" + } +} diff --git a/server/server.js b/server/server.js new file mode 100755 index 0000000..6be4cee --- /dev/null +++ b/server/server.js @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +'use strict'; + +process.title = 'mediasoup-demo-server'; + +const config = require('./config'); + +process.env.DEBUG = config.debug || '*LOG* *WARN* *ERROR*'; + +console.log('- process.env.DEBUG:', process.env.DEBUG); +console.log('- config.mediasoup.logLevel:', config.mediasoup.logLevel); +console.log('- config.mediasoup.logTags:', config.mediasoup.logTags); + +const fs = require('fs'); +const https = require('https'); +const url = require('url'); +const protooServer = require('protoo-server'); +const mediasoup = require('mediasoup'); +const readline = require('readline'); +const colors = require('colors/safe'); +const repl = require('repl'); +const logger = require('./lib/logger')(); +const Room = require('./lib/Room'); + +// Map of Room instances indexed by roomId. +let rooms = new Map(); + +// mediasoup server. +let mediaServer = mediasoup.Server( + { + numWorkers : 1, + logLevel : config.mediasoup.logLevel, + logTags : config.mediasoup.logTags, + rtcIPv4 : config.mediasoup.rtcIPv4, + rtcIPv6 : config.mediasoup.rtcIPv6, + rtcAnnouncedIPv4 : config.mediasoup.rtcAnnouncedIPv4, + rtcAnnouncedIPv6 : config.mediasoup.rtcAnnouncedIPv6, + rtcMinPort : config.mediasoup.rtcMinPort, + rtcMaxPort : config.mediasoup.rtcMaxPort + }); + +global.SERVER = mediaServer; +mediaServer.on('newroom', (room) => +{ + global.ROOM = room; +}); + +// HTTPS server for the protoo WebSocjet server. +let tls = +{ + cert : fs.readFileSync(config.tls.cert), + key : fs.readFileSync(config.tls.key) +}; +let httpsServer = https.createServer(tls, (req, res) => + { + res.writeHead(404, 'Not Here'); + res.end(); + }); + +httpsServer.listen(config.protoo.listenPort, config.protoo.listenIp, () => +{ + logger.log('protoo WebSocket server running'); +}); + +// Protoo WebSocket server. +let webSocketServer = new protooServer.WebSocketServer(httpsServer, + { + maxReceivedFrameSize : 960000, // 960 KBytes. + maxReceivedMessageSize : 960000, + fragmentOutgoingMessages : true, + fragmentationThreshold : 960000 + }); + +// Handle connections from clients. +webSocketServer.on('connectionrequest', (info, accept, reject) => +{ + // The client indicates the roomId and peerId in the URL query. + let u = url.parse(info.request.url, true); + let roomId = u.query['room-id']; + let peerId = u.query['peer-id']; + + if (!roomId || !peerId) + { + logger.warn('connection request without roomId and/or peerId'); + + reject(400, 'Connection request without roomId and/or peerId'); + return; + } + + logger.log('connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); + + // If an unknown roomId, create a new Room. + if (!rooms.has(roomId)) + { + logger.debug('creating a new Room [roomId:"%s"]', roomId); + + let room = new Room(roomId, mediaServer); + let logStatusTimer = setInterval(() => + { + room.logStatus(); + }, 10000); + + rooms.set(roomId, room); + + room.on('close', () => + { + rooms.delete(roomId); + clearInterval(logStatusTimer); + }); + } + + let room = rooms.get(roomId); + let transport = accept(); + + room.createProtooPeer(peerId, transport) + .catch((error) => + { + logger.error('error creating a protoo peer: %s', error); + }); +}); + +// Listen for keyboard input. + +let cmd; +let terminal; + +function openCommandConsole() +{ + stdinLog('[opening Readline Command Console...]'); + + closeCommandConsole(); + closeTerminal(); + + cmd = readline.createInterface( + { + input : process.stdin, + output : process.stdout + }); + + cmd.on('SIGINT', () => + { + process.exit(); + }); + + readStdin(); + + function readStdin() + { + cmd.question('cmd> ', (answer) => + { + switch (answer) + { + case '': + { + readStdin(); + break; + } + + case 'h': + case 'help': + { + stdinLog(''); + stdinLog('available commands:'); + stdinLog('- h, help : show this message'); + stdinLog('- sd, serverdump : execute server.dump()'); + stdinLog('- rd, roomdump : execute room.dump() for the latest created mediasoup Room'); + stdinLog('- t, terminal : open REPL Terminal'); + stdinLog(''); + readStdin(); + + break; + } + + case 'sd': + case 'serverdump': + { + mediaServer.dump() + .then((data) => + { + stdinLog(`mediaServer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`); + readStdin(); + }) + .catch((error) => + { + stdinError(`mediaServer.dump() failed: ${error}`); + readStdin(); + }); + + break; + } + + case 'rd': + case 'roomdump': + { + if (!global.ROOM) + { + readStdin(); + break; + } + + global.ROOM.dump() + .then((data) => + { + stdinLog('global.ROOM.dump() succeeded'); + stdinLog(`- peers:\n${JSON.stringify(data.peers, null, ' ')}`); + stdinLog(`- num peers: ${data.peers.length}`); + readStdin(); + }) + .catch((error) => + { + stdinError(`global.ROOM.dump() failed: ${error}`); + readStdin(); + }); + + break; + } + + case 't': + case 'terminal': + { + openTerminal(); + + break; + } + + default: + { + stdinError(`unknown command: ${answer}`); + stdinLog('press \'h\' or \'help\' to get the list of available commands'); + + readStdin(); + } + } + }); + } +} + +function openTerminal() +{ + stdinLog('[opening REPL Terminal...]'); + + closeCommandConsole(); + closeTerminal(); + + terminal = repl.start({ + prompt : 'terminal> ', + useColors : true, + useGlobal : true, + ignoreUndefined : true + }); + + terminal.on('exit', () => + { + process.exit(); + }); +} + +function closeCommandConsole() +{ + if (cmd) + { + cmd.close(); + cmd = undefined; + } +} + +function closeTerminal() +{ + if (terminal) + { + terminal.removeAllListeners('exit'); + terminal.close(); + terminal = undefined; + } +} + +openCommandConsole(); + +// Export openCommandConsole function by typing 'c'. +Object.defineProperty(global, 'c', + { + get : function() + { + openCommandConsole(); + } + }); + +function stdinLog(msg) +{ + console.log(colors.green(msg)); +} + +function stdinError(msg) +{ + console.error(colors.red.bold('ERROR: ') + colors.red(msg)); +}