Make demo public
commit
f1658f1b3c
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
* <%= pkg.name %> v<%= pkg.version %>
|
||||||
|
* <%= pkg.description %>
|
||||||
|
* Copyright: 2017-<%= currentYear %> <%= pkg.author %>
|
||||||
|
* License: <%= pkg.license %>
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports =
|
||||||
|
{
|
||||||
|
protoo :
|
||||||
|
{
|
||||||
|
listenPort : 3443
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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'));
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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)});
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
[data-component='App'] {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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-----
|
||||||
|
|
@ -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-----
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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'));
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue