Make demo public

master
Iñaki Baz Castillo 2017-04-23 14:54:30 +02:00
commit f1658f1b3c
54 changed files with 5241 additions and 0 deletions

11
.gitignore vendored 100644
View File

@ -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

97
README.md 100644
View File

@ -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.

7
app/banner.txt 100644
View File

@ -0,0 +1,7 @@
/*
* <%= pkg.name %> v<%= pkg.version %>
* <%= pkg.description %>
* Copyright: 2017-<%= currentYear %> <%= pkg.author %>
* License: <%= pkg.license %>
*/

View File

@ -0,0 +1,7 @@
module.exports =
{
protoo :
{
listenPort : 3443
}
};

382
app/gulpfile.js 100644
View File

@ -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'));

29
app/index.html 100644
View File

@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<title>mediasoup demo</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'>
<meta name='description' content='mediasoup demo - Cutting Edge WebRTC Video Conferencing'>
<link rel='stylesheet' href='/mediasoup-demo-app.css'>
<script src='/resources/js/antiglobal.js'></script>
<script>
window.localStorage.setItem('debug', '* -engine* -socket* *WARN* *ERROR*');
if (window.antiglobal)
{
window.antiglobal('___browserSync___oldSocketIo', 'io', '___browserSync___', '__core-js_shared__', 'RTCPeerConnection');
setInterval(window.antiglobal, 5000);
}
</script>
<script async src='/mediasoup-demo-app.js'></script>
</head>
<body>
<div id='mediasoup-demo-app-container'></div>
<div id='mediasoup-demo-app-media-query-detector'></div>
</body>
</html>

897
app/lib/Client.js 100644
View File

@ -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');
}
}
}

43
app/lib/Logger.js 100644
View File

@ -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;
}
}

View File

@ -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 (
<MuiThemeProvider muiTheme={muiTheme}>
<div data-component='App'>
<Notifier ref='Notifier'/>
<Room
peerId={props.peerId}
roomId={props.roomId}
onNotify={this.handleNotify.bind(this)}
onHideNotification={this.handleHideNotification.bind(this)}
/>
</div>
</MuiThemeProvider>
);
}
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
};

View File

@ -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 (
<div data-component='LocalVideo' className={`state-${props.connectionState}`}>
{props.stream ?
<Video
stream={props.stream}
resolution={props.resolution}
muted
mirror={props.webcamType === 'front'}
onResolutionChange={this.handleResolutionChange.bind(this)}
/>
:null}
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteMic.bind(this)}
>
<MicOffIcon
color={!state.micMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
disabled={state.togglingWebcam}
onClick={this.handleClickWebcam.bind(this)}
>
<VideoCamOffIcon
color={state.webcam ? '#fff' : '#ff8a00'}
/>
</IconButton>
{props.multipleWebcams ?
<IconButton
className='control'
disabled={!state.webcam || state.togglingWebcam}
onClick={this.handleClickChangeWebcam.bind(this)}
>
<ChangeVideoCamIcon
color='#fff'
/>
</IconButton>
:null}
</div>
<div className='info'>
<div className='peer-id'>{props.peerId}</div>
</div>
</div>
);
}
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
};

View File

@ -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 (
<NotificationSystem ref='NotificationSystem' style={STYLE} allowHTML={false}/>
);
}
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);
}
}

View File

@ -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 (
<div
data-component='RemoteVideo'
className={classnames({ fullsize: !!props.fullsize })}
>
<Video
stream={props.stream}
muted={state.audioMuted}
/>
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteAudio.bind(this)}
>
<VolumeOffIcon
color={!state.audioMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
onClick={this.handleClickDisableVideo.bind(this)}
>
<VideoOffIcon
color={hasVideo ? '#fff' : '#ff8a00'}
/>
</IconButton>
</div>
<div className='info'>
<div className='peer-id'>{props.peer.id}</div>
</div>
</div>
);
}
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
};

View File

@ -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 (
<TransitionAppear duration={2000}>
<div data-component='Room'>
<div className='room-link-wrapper'>
<div className='room-link'>
<ClipboardButton
component='a'
className='link'
button-href={window.location.href}
data-clipboard-text={window.location.href}
onSuccess={this.handleRoomLinkCopied.bind(this)}
onClick={() => {}} // Avoid link action.
>
invite people to this room
</ClipboardButton>
</div>
</div>
<div className='remote-videos'>
{
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 (
<TransitionAppear key={msid} duration={500}>
<RemoteVideo
peer={peer}
stream={stream}
fullsize={numPeers === 1}
onDisableVideo={this.handleDisableRemoteVideo.bind(this)}
onEnableVideo={this.handleEnableRemoteVideo.bind(this)}
/>
</TransitionAppear>
);
})
}
</div>
<TransitionAppear duration={500}>
<div className='local-video'>
<LocalVideo
peerId={props.peerId}
stream={state.localStream}
resolution={state.localVideoResolution}
multipleWebcams={state.multipleWebcams}
webcamType={state.webcamType}
connectionState={state.connectionState}
onMicMute={this.handleLocalMute.bind(this)}
onWebcamToggle={this.handleLocalWebcamToggle.bind(this)}
onWebcamChange={this.handleLocalWebcamChange.bind(this)}
onResolutionChange={this.handleLocalResolutionChange.bind(this)}
/>
{state.showStats ?
<TransitionAppear duration={500}>
<Stats
stats={state.stats || new Map()}
onClose={this.handleStatsClose.bind(this)}
/>
</TransitionAppear>
:
<div
className='show-stats'
onClick={this.handleClickShowStats.bind(this)}
/>
}
</div>
</TransitionAppear>
</div>
</TransitionAppear>
);
}
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
};

View File

@ -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 (
<div data-component='Stats'>
<div
className='close'
onClick={this.handleCloseClick.bind(this)}
/>
{
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 (
<div key={itemName} className='item'>
<div className='key'>{itemName}</div>
<div className='value'>{value}</div>
</div>
);
});
if (!items.length)
return null;
return (
<div key={blockName} className='block'>
<h1>{blockName}</h1>
{items}
</div>
);
})
}
</div>
);
}
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
};

View File

@ -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 (
<ReactCSSTransitionGroup
component={FakeTransitionWrapper}
transitionName='transition'
transitionAppear={!!duration}
transitionAppearTimeout={duration}
transitionEnter={false}
transitionLeave={false}
>
{this.props.children}
</ReactCSSTransitionGroup>
);
}
}
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
};

View File

@ -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 (
<div data-component='Video'>
{state.width ?
(
<div
className={classnames('resolution', { clickable: !!props.resolution })}
onClick={this.handleResolutionClick.bind(this)}
>
<p>{state.width}x{state.height}</p>
{props.resolution ?
<p>{props.resolution}</p>
:null}
</div>
)
:null}
<div className='volume'>
<div className={classnames('bar', `level${state.volume}`)}/>
</div>
<video
ref='video'
className={classnames({ mirror: props.mirror })}
autoPlay
muted={props.muted}
/>
</div>
);
}
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
};

View File

@ -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;

56
app/lib/index.jsx 100644
View File

@ -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(<App peerId={peerId} roomId={roomId}/>, container);
}

View File

@ -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;
}
};

52
app/lib/utils.js 100644
View File

@ -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();
}
}
};

60
app/package.json 100644
View File

@ -0,0 +1,60 @@
{
"name": "mediasoup-demo-app",
"version": "1.0.0",
"private": true,
"description": "mediasoup demo app",
"author": "Iñaki Baz Castillo <ibc@aliax.net>",
"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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="48"
height="48"
id="svg4347"
version="1.1"
inkscape:version="0.48.4 r9939"
sodipodi:docname="buddy.svg">
<defs
id="defs4349" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="8"
inkscape:cx="5.3985295"
inkscape:cy="12.974855"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
showborder="true"
inkscape:window-width="979"
inkscape:window-height="809"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0" />
<metadata
id="metadata4352">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1004.3622)">
<rect
style="fill:none"
y="1004.8992"
x="1.3571341"
height="47.070076"
width="44.699638"
id="rect4438" />
<path
inkscape:connector-curvature="0"
d="m 29.874052,1037.8071 c -0.170513,-1.8828 -0.105106,-3.1968 -0.105106,-4.917 0.852576,-0.4474 2.380226,-3.2994 2.638334,-5.7089 0.670389,-0.054 1.727363,-0.7089 2.036865,-3.2912 0.167015,-1.3862 -0.496371,-2.1665 -0.900473,-2.4118 1.090848,-3.2807 3.356621,-13.4299 -4.190513,-14.4788 -0.776672,-1.364 -2.765648,-2.0544 -5.350262,-2.0544 -10.340807,0.1905 -11.588155,7.8088 -9.321205,16.5332 -0.402943,0.2453 -1.066322,1.0256 -0.900475,2.4118 0.310672,2.5823 1.366476,3.2362 2.036857,3.2912 0.256949,2.4083 1.845322,5.2615 2.700243,5.7089 0,1.7202 0.06426,3.0342 -0.106272,4.917 -2.046213,5.5007 -15.8522501,3.9567 -16.4899366,14.5661 H 46.303254 c -0.636524,-10.6094 -14.38299,-9.0654 -16.429202,-14.5661 z"
id="path4440"
style="opacity:0.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="tiny" x="0px" y="0px" width="480px" height="480px" viewBox="0 0 480 480" xml:space="preserve">
<g id="all_friends">
<path d="M185.161,285.818c-9.453-7.317-17-18.963-20.438-28.691c-0.406-0.371-0.949-0.846-1.373-1.23 c-5.297-4.638-15.156-13.284-15.156-32.656c0-9.143,1.748-15.652,5.514-20.48c0.518-0.651,1.174-1.407,1.984-2.188v-19.394 c0-23.428,16.861-54.488,53.471-65.856c-6.391-14.644-22.232-30.543-53.656-30.543c-43.455,0-57.205,30.422-57.205,45.851 c0,8.313,0,22.479,0,23.514c0,1.049-6.188-2.826-6.188,11.153c0,15.946,10.752,17.047,12.658,23.061 c3.375,10.691,13.877,22.314,20.826,22.314c0,0,0.531,2.209,0.531,6.278c0,3.296-0.078,3.24-2.402,3.24 c-37.77,0-63.721,39.909-63.721,52.653c0,16.292,0,34.201,0,34.201h80.076C153.303,297.144,168.805,289.123,185.161,285.818z"/>
<path d="M356.256,220.19c-2.312,0-2.408,0.056-2.408-3.24c0-4.069,0.531-6.278,0.531-6.278c6.955,0,17.469-11.623,20.844-22.314 c1.906-6.014,12.658-7.114,12.658-23.061c0-13.979-6.174-10.104-6.174-11.153c0-1.035,0-15.2,0-23.514 c0-15.429-13.781-45.851-57.232-45.851c-31.486,0-47.342,15.985-53.701,30.659c36.389,11.459,53.154,42.386,53.154,65.74v19.397 c4.125,3.957,7.486,10.812,7.486,22.664c0,19.377-9.859,28.022-15.158,32.661c-0.439,0.384-0.971,0.854-1.375,1.229 c-3.422,9.733-10.986,21.37-20.439,28.688c16.379,3.313,31.877,11.334,45.078,21.227h80.488c0,0,0-17.909,0-34.201 C420.008,260.1,394.04,220.19,356.256,220.19z"/>
<path d="M278.368,298.86c-2.814,0-2.926-8.948-2.926-12.952c0-4.927,0.643-7.619,0.643-7.619c8.439,0,21.189-14.091,25.283-27.064 c2.312-7.296,15.355-8.634,15.355-27.984c0-16.956-7.498-12.257-7.498-13.525c0-1.26,0-18.445,0-28.536 c0-18.712-16.703-55.618-69.422-55.618c-52.705,0-69.406,36.906-69.406,55.618c0,10.091,0,27.276,0,28.536 c0,1.269-7.516-3.431-7.516,13.525c0,19.351,13.047,20.688,15.359,27.984c4.107,12.974,16.844,27.064,25.283,27.064 c0,0,0.639,2.692,0.639,7.619c0,4.004-0.096,12.952-2.922,12.952c-45.811,0-86.328,48.422-86.328,63.89c0,19.765,0,32.467,0,32.467 h124.891h124.893c0,0,0-12.702,0-32.467C364.696,347.282,324.178,298.86,278.368,298.86z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="36" viewBox="0 0 24 24" width="36" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -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<t.length;i++)r(t[i]);return r}({1:[function(e,n,o){(function(e){function o(){var e,n,o,u=t(),l=Array.prototype.slice.call(arguments),a=[],d=[],c=!1;for(e=0,n=u.length;e<n;e++)o=u[e],r.indexOf(o)===-1&&l.indexOf(o)===-1&&(a.push(o),c=!0);for(e=0,n=r.length;e<n;e++)o=r[e],u.indexOf(o)===-1&&(d.push(o),c=!0);if(r=u.concat(l),c){var s="antiglobal() | globals do not match:";for(e=0,n=a.length;e<n;e++)o=a[e],s=s+"\n+ "+o;for(e=0,n=d.length;e<n;e++)o=d[e],s=s+"\n- "+o;if(f&&console.error(s),i)throw new Error(s)}return!c}function t(){var n=[];for(var o in e)e.hasOwnProperty(o)&&"antiglobal"!==o&&n.push(o);return n}var r=t(),f=!0,i=!1;o.reset=function(){r=t()},Object.defineProperties(o,{log:{get:function(){return f},set:function(e){f=Boolean(e)}},throw:{get:function(){return i},set:function(e){i=Boolean(e)}}}),n.exports=o}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)});

View File

@ -0,0 +1,5 @@
[data-component='App'] {
position: relative;
min-height: 100vh;
width: 100%;
}

View File

@ -0,0 +1,96 @@
[data-component='LocalVideo'] {
position: relative;
flex: 0 0 auto;
TransitionAppear(500ms);
+desktop() {
height: 220px;
width: 220px;
border: 2px solid rgba(#fff, 0.5);
}
+mobile() {
height: 180px;
width: 180px;
border: 2px solid rgba(#fff, 0.5);
}
&.state-checking {
border-color: orange;
}
&.state-checking {
animation: LocalVideo-state-checking .75s infinite linear;
}
&.state-connected,
&.state-completed {
border-color: rgba(#49ce3e, 0.9);
}
&.state-failed,
&.state-disconnected,
&.state-closed {
border-color: rgba(#ff2000, 0.75);
}
> .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);
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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%;
}
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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;

View File

@ -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-----

View File

@ -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-----

View File

@ -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
}
};

81
server/gulpfile.js 100644
View File

@ -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'));

498
server/lib/Room.js 100644
View File

@ -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;

View File

@ -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);
};

View File

@ -0,0 +1,25 @@
{
"name": "mediasoup-demo-server",
"version": "1.0.0",
"private": true,
"description": "mediasoup demo server",
"author": "Iñaki Baz Castillo <ibc@aliax.net>",
"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"
}
}

297
server/server.js 100755
View File

@ -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));
}