Make demo public

This commit is contained in:
Iñaki Baz Castillo
2017-04-23 14:54:30 +02:00
commit f1658f1b3c
54 changed files with 5241 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/*
* <%= pkg.name %> v<%= pkg.version %>
* <%= pkg.description %>
* Copyright: 2017-<%= currentYear %> <%= pkg.author %>
* License: <%= pkg.license %>
*/
+7
View File
@@ -0,0 +1,7 @@
module.exports =
{
protoo :
{
listenPort : 3443
}
};
+382
View File
@@ -0,0 +1,382 @@
'use strict';
/**
* Tasks:
*
* gulp prod
* Generates the browser app in production mode.
*
* gulp dev
* Generates the browser app in development mode.
*
* gulp live
* Generates the browser app in development mode, opens it and watches
* for changes in the source code.
*
* gulp
* Alias for `gulp live`.
*/
const fs = require('fs');
const path = require('path');
const gulp = require('gulp');
const gulpif = require('gulp-if');
const gutil = require('gulp-util');
const plumber = require('gulp-plumber');
const touch = require('gulp-touch');
const rename = require('gulp-rename');
const header = require('gulp-header');
const browserify = require('browserify');
const watchify = require('watchify');
const envify = require('envify/custom');
const uglify = require('gulp-uglify');
const source = require('vinyl-source-stream');
const buffer = require('vinyl-buffer');
const del = require('del');
const mkdirp = require('mkdirp');
const ncp = require('ncp');
const eslint = require('gulp-eslint');
const stylus = require('gulp-stylus');
const cssBase64 = require('gulp-css-base64');
const nib = require('nib');
const browserSync = require('browser-sync');
const PKG = require('./package.json');
const BANNER = fs.readFileSync('banner.txt').toString();
const BANNER_OPTIONS =
{
pkg : PKG,
currentYear : (new Date()).getFullYear()
};
const OUTPUT_DIR = '../server/public';
// Default environment.
process.env.NODE_ENV = 'development';
function logError(error)
{
gutil.log(gutil.colors.red(String(error)));
throw error;
}
function bundle(options)
{
options = options || {};
let watch = !!options.watch;
let bundler = browserify(
{
entries : path.join(__dirname, PKG.main),
extensions : [ '.js', '.jsx' ],
// required for sourcemaps (must be false otherwise).
debug : process.env.NODE_ENV === 'development',
// required for watchify.
cache : {},
// required for watchify.
packageCache : {},
// required to be true only for watchify.
fullPaths : watch
})
.transform('babelify',
{
presets : [ 'es2015', 'react' ],
plugins : [ 'transform-runtime', 'transform-object-assign' ]
})
.transform(envify(
{
NODE_ENV : process.env.NODE_ENV,
_ : 'purge'
}));
if (watch)
{
bundler = watchify(bundler);
bundler.on('update', () =>
{
let start = Date.now();
gutil.log('bundling...');
rebundle();
gutil.log('bundle took %sms', (Date.now() - start));
});
}
function rebundle()
{
return bundler.bundle()
.on('error', logError)
.pipe(source(`${PKG.name}.js`))
.pipe(buffer())
.pipe(rename(`${PKG.name}.js`))
.pipe(gulpif(process.env.NODE_ENV === 'production',
uglify()
))
.pipe(header(BANNER, BANNER_OPTIONS))
.pipe(gulp.dest(OUTPUT_DIR));
}
return rebundle();
}
gulp.task('clean', () => del(OUTPUT_DIR, { force: true }));
gulp.task('env:dev', (done) =>
{
gutil.log('setting "dev" environment');
process.env.NODE_ENV = 'development';
done();
});
gulp.task('env:prod', (done) =>
{
gutil.log('setting "prod" environment');
process.env.NODE_ENV = 'production';
done();
});
gulp.task('lint', () =>
{
let src = [ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ];
return gulp.src(src)
.pipe(plumber())
.pipe(eslint(
{
plugins : [ 'react', 'import' ],
extends : [ 'eslint:recommended', 'plugin:react/recommended' ],
settings :
{
react :
{
pragma : 'React', // Pragma to use, default to 'React'.
version : '15' // React version, default to the latest React stable release.
}
},
parserOptions :
{
ecmaVersion : 6,
sourceType : 'module',
ecmaFeatures :
{
impliedStrict : true,
jsx : true
}
},
envs :
[
'browser',
'es6',
'node',
'commonjs'
],
'rules' :
{
'no-console' : 0,
'no-undef' : 2,
'no-unused-vars' : [ 2, { vars: 'all', args: 'after-used' }],
'no-empty' : 0,
'quotes' : [ 2, 'single', { avoidEscape: true } ],
'semi' : [ 2, 'always' ],
'no-multi-spaces' : 0,
'no-whitespace-before-property' : 2,
'space-before-blocks' : 2,
'space-before-function-paren' : [ 2, 'never' ],
'space-in-parens' : [ 2, 'never' ],
'spaced-comment' : [ 2, 'always' ],
'comma-spacing' : [ 2, { before: false, after: true } ],
'jsx-quotes' : [ 2, 'prefer-single' ],
'react/display-name' : [ 2, { ignoreTranspilerName: false } ],
'react/forbid-prop-types' : 0,
'react/jsx-boolean-value' : 1,
'react/jsx-closing-bracket-location' : 1,
'react/jsx-curly-spacing' : 1,
'react/jsx-equals-spacing' : 1,
'react/jsx-handler-names' : 1,
'react/jsx-indent-props' : [ 2, 'tab' ],
'react/jsx-indent' : [ 2, 'tab' ],
'react/jsx-key' : 1,
'react/jsx-max-props-per-line' : 0,
'react/jsx-no-bind' : 0,
'react/jsx-no-duplicate-props' : 1,
'react/jsx-no-literals' : 0,
'react/jsx-no-undef' : 1,
'react/jsx-pascal-case' : 1,
'react/jsx-sort-prop-types' : 0,
'react/jsx-sort-props' : 0,
'react/jsx-uses-react' : 1,
'react/jsx-uses-vars' : 1,
'react/no-danger' : 1,
'react/no-deprecated' : 1,
'react/no-did-mount-set-state' : 1,
'react/no-did-update-set-state' : 1,
'react/no-direct-mutation-state' : 1,
'react/no-is-mounted' : 1,
'react/no-multi-comp' : 0,
'react/no-set-state' : 0,
'react/no-string-refs' : 0,
'react/no-unknown-property' : 1,
'react/prefer-es6-class' : 1,
'react/prop-types' : 1,
'react/react-in-jsx-scope' : 1,
'react/self-closing-comp' : 1,
'react/sort-comp' : 0,
'react/jsx-wrap-multilines' : [ 1, { declaration: false, assignment: false, return: true } ],
'import/extensions' : 1
}
}))
.pipe(eslint.format());
});
gulp.task('css', () =>
{
return gulp.src('stylus/index.styl')
.pipe(plumber())
.pipe(stylus(
{
use : nib(),
compress : process.env.NODE_ENV === 'production'
}))
.on('error', logError)
.pipe(cssBase64(
{
baseDir : '.',
maxWeightResource : 50000 // So big ttf fonts are not included, nice.
}))
.pipe(rename(`${PKG.name}.css`))
.pipe(gulp.dest(OUTPUT_DIR))
.pipe(touch());
});
gulp.task('html', () =>
{
return gulp.src('index.html')
.pipe(gulp.dest(OUTPUT_DIR));
});
gulp.task('resources', (done) =>
{
let dst = path.join(OUTPUT_DIR, 'resources');
mkdirp.sync(dst);
ncp('resources', dst, { stopOnErr: true }, (error) =>
{
if (error && error[0].code !== 'ENOENT')
throw new Error(`resources copy failed: ${error}`);
done();
});
});
gulp.task('bundle', () =>
{
return bundle({ watch: false });
});
gulp.task('bundle:watch', () =>
{
return bundle({ watch: true });
});
gulp.task('livebrowser', (done) =>
{
const config = require('../server/config');
browserSync(
{
open : 'external',
host : config.domain,
server :
{
baseDir : OUTPUT_DIR
},
https : config.tls,
ghostMode : false,
files : path.join(OUTPUT_DIR, '**', '*')
});
done();
});
gulp.task('browser', (done) =>
{
const config = require('../server/config');
browserSync(
{
open : 'external',
host : config.domain,
server :
{
baseDir : OUTPUT_DIR
},
https : config.tls,
ghostMode : false
});
done();
});
gulp.task('watch', (done) =>
{
// Watch changes in HTML.
gulp.watch([ 'index.html' ], gulp.series(
'html'
));
// Watch changes in Stylus files.
gulp.watch([ 'stylus/**/*.styl' ], gulp.series(
'css'
));
// Watch changes in resources.
gulp.watch([ 'resources/**/*' ], gulp.series(
'resources', 'css'
));
// Watch changes in JS files.
gulp.watch([ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ], gulp.series(
'lint'
));
done();
});
gulp.task('prod', gulp.series(
'env:prod',
'clean',
'lint',
'bundle',
'html',
'css',
'resources'
));
gulp.task('dev', gulp.series(
'env:dev',
'clean',
'lint',
'bundle',
'html',
'css',
'resources'
));
gulp.task('live', gulp.series(
'env:dev',
'clean',
'lint',
'bundle:watch',
'html',
'css',
'resources',
'watch',
'livebrowser'
));
gulp.task('open', gulp.series('browser'));
gulp.task('default', gulp.series('live'));
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<title>mediasoup demo</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'>
<meta name='description' content='mediasoup demo - Cutting Edge WebRTC Video Conferencing'>
<link rel='stylesheet' href='/mediasoup-demo-app.css'>
<script src='/resources/js/antiglobal.js'></script>
<script>
window.localStorage.setItem('debug', '* -engine* -socket* *WARN* *ERROR*');
if (window.antiglobal)
{
window.antiglobal('___browserSync___oldSocketIo', 'io', '___browserSync___', '__core-js_shared__', 'RTCPeerConnection');
setInterval(window.antiglobal, 5000);
}
</script>
<script async src='/mediasoup-demo-app.js'></script>
</head>
<body>
<div id='mediasoup-demo-app-container'></div>
<div id='mediasoup-demo-app-media-query-detector'></div>
</body>
</html>
+897
View File
@@ -0,0 +1,897 @@
'use strict';
import events from 'events';
import browser from 'bowser';
import sdpTransform from 'sdp-transform';
import Logger from './Logger';
import protooClient from 'protoo-client';
import urlFactory from './urlFactory';
import utils from './utils';
const logger = new Logger('Client');
const DO_GETUSERMEDIA = true;
const ENABLE_SIMULCAST = false;
const VIDEO_CONSTRAINS =
{
qvga : { width: { ideal: 320 }, height: { ideal: 240 }},
vga : { width: { ideal: 640 }, height: { ideal: 480 }},
hd : { width: { ideal: 1280 }, height: { ideal: 720 }}
};
export default class Client extends events.EventEmitter
{
constructor(peerId, roomId)
{
logger.debug('constructor() [peerId:"%s", roomId:"%s"]', peerId, roomId);
super();
this.setMaxListeners(Infinity);
let url = urlFactory.getProtooUrl(peerId, roomId);
let transport = new protooClient.WebSocketTransport(url);
// protoo-client Peer instance.
this._protooPeer = new protooClient.Peer(transport);
// RTCPeerConnection instance.
this._peerconnection = null;
// Webcam map indexed by deviceId.
this._webcams = new Map();
// Local Webcam device.
this._webcam = null;
// Local MediaStream instance.
this._localStream = null;
// Closed flag.
this._closed = false;
// Local video resolution.
this._localVideoResolution = 'vga';
this._protooPeer.on('open', () =>
{
logger.debug('protoo Peer "open" event');
});
this._protooPeer.on('disconnected', () =>
{
logger.warn('protoo Peer "disconnected" event');
// Close RTCPeerConnection.
try
{
this._peerconnection.close();
}
catch (error) {}
// Close local MediaStream.
if (this._localStream)
utils.closeMediaStream(this._localStream);
this.emit('disconnected');
});
this._protooPeer.on('close', () =>
{
if (this._closed)
return;
logger.warn('protoo Peer "close" event');
this.close();
});
this._protooPeer.on('request', this._handleRequest.bind(this));
}
close()
{
if (this._closed)
return;
this._closed = true;
logger.debug('close()');
// Close protoo Peer.
this._protooPeer.close();
// Close RTCPeerConnection.
try
{
this._peerconnection.close();
}
catch (error) {}
// Close local MediaStream.
if (this._localStream)
utils.closeMediaStream(this._localStream);
// Emit 'close' event.
this.emit('close');
}
removeVideo(dontNegotiate)
{
logger.debug('removeVideo()');
let stream = this._localStream;
let videoTrack = stream.getVideoTracks()[0];
if (!videoTrack)
{
logger.warn('removeVideo() | no video track');
return Promise.reject(new Error('no video track'));
}
stream.removeTrack(videoTrack);
videoTrack.stop();
// NOTE: For Firefox (modern WenRTC API).
if (this._peerconnection.removeTrack)
{
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === videoTrack)
break;
}
this._peerconnection.removeTrack(sender);
}
if (!dontNegotiate)
{
this.emit('localstream', stream, null);
return this._requestRenegotiation();
}
}
addVideo()
{
logger.debug('addVideo()');
let stream = this._localStream;
let videoTrack;
let videoResolution = this._localVideoResolution; // Keep previous resolution.
if (stream)
videoTrack = stream.getVideoTracks()[0];
if (videoTrack)
{
logger.warn('addVideo() | there is already a video track');
return Promise.reject(new Error('there is already a video track'));
}
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[videoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
if (stream)
{
stream.addTrack(newVideoTrack);
// NOTE: For Firefox (modern WenRTC API).
if (this._peerconnection.addTrack)
this._peerconnection.addTrack(newVideoTrack, this._localStream);
}
else
{
this._localStream = newStream;
this._peerconnection.addStream(newStream);
}
this.emit('localstream', this._localStream, videoResolution);
})
.then(() =>
{
return this._requestRenegotiation();
})
.catch((error) =>
{
logger.error('addVideo() failed: %o', error);
throw error;
});
}
changeWebcam()
{
logger.debug('changeWebcam()');
return Promise.resolve()
.then(() =>
{
return this._updateWebcams();
})
.then(() =>
{
let array = Array.from(this._webcams.keys());
let len = array.length;
let deviceId = this._webcam ? this._webcam.deviceId : undefined;
let idx = array.indexOf(deviceId);
if (idx < len - 1)
idx++;
else
idx = 0;
this._webcam = this._webcams.get(array[idx]);
this._emitWebcamType();
if (len < 2)
return;
logger.debug(
'changeWebcam() | new selected webcam [deviceId:"%s"]',
this._webcam.deviceId);
// Reset video resolution to VGA.
this._localVideoResolution = 'vga';
// For Chrome (old WenRTC API).
// Replace the track (so new SSRC) and renegotiate.
if (!this._peerconnection.removeTrack)
{
this.removeVideo(true);
return this.addVideo();
}
// For Firefox (modern WebRTC API).
// Avoid renegotiation.
else
{
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[this._localVideoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
let stream = this._localStream;
let oldVideoTrack = stream.getVideoTracks()[0];
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === oldVideoTrack)
break;
}
sender.replaceTrack(newVideoTrack);
stream.removeTrack(oldVideoTrack);
oldVideoTrack.stop();
stream.addTrack(newVideoTrack);
this.emit('localstream', stream, this._localVideoResolution);
});
}
})
.catch((error) =>
{
logger.error('changeWebcam() failed: %o', error);
});
}
changeVideoResolution()
{
logger.debug('changeVideoResolution()');
let newVideoResolution;
switch (this._localVideoResolution)
{
case 'qvga':
newVideoResolution = 'vga';
break;
case 'vga':
newVideoResolution = 'hd';
break;
case 'hd':
newVideoResolution = 'qvga';
break;
default:
throw new Error(`unknown resolution "${this._localVideoResolution}"`);
}
this._localVideoResolution = newVideoResolution;
// For Chrome (old WenRTC API).
// Replace the track (so new SSRC) and renegotiate.
if (!this._peerconnection.removeTrack)
{
this.removeVideo(true);
return this.addVideo();
}
// For Firefox (modern WebRTC API).
// Avoid renegotiation.
else
{
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[this._localVideoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
let stream = this._localStream;
let oldVideoTrack = stream.getVideoTracks()[0];
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === oldVideoTrack)
break;
}
sender.replaceTrack(newVideoTrack);
stream.removeTrack(oldVideoTrack);
oldVideoTrack.stop();
stream.addTrack(newVideoTrack);
this.emit('localstream', stream, newVideoResolution);
})
.catch((error) =>
{
logger.error('changeVideoResolution() failed: %o', error);
});
}
}
getStats()
{
return this._peerconnection.getStats()
.catch((error) =>
{
logger.error('pc.getStats() failed: %o', error);
throw error;
});
}
disableRemoteVideo(msid)
{
this._protooPeer.send('disableremotevideo', { msid, disable: true })
.catch((error) =>
{
logger.warn('disableRemoteVideo() failed: %o', error);
});
}
enableRemoteVideo(msid)
{
this._protooPeer.send('disableremotevideo', { msid, disable: false })
.catch((error) =>
{
logger.warn('enableRemoteVideo() failed: %o', error);
});
}
_handleRequest(request, accept, reject)
{
logger.debug('_handleRequest() [method:%s, data:%o]', request.method, request.data);
switch(request.method)
{
case 'joinme':
{
let videoResolution = this._localVideoResolution;
Promise.resolve()
.then(() =>
{
return this._updateWebcams();
})
.then(() =>
{
if (DO_GETUSERMEDIA)
{
return this._getLocalStream(
{
audio : true,
video : VIDEO_CONSTRAINS[videoResolution]
})
.then((stream) =>
{
logger.debug('got local stream [resolution:%s]', videoResolution);
// Close local MediaStream if any.
if (this._localStream)
utils.closeMediaStream(this._localStream);
this._localStream = stream;
// Emit 'localstream' event.
this.emit('localstream', stream, videoResolution);
});
}
})
.then(() =>
{
return this._createPeerConnection();
})
.then(() =>
{
return this._peerconnection.createOffer(
{
offerToReceiveAudio : 1,
offerToReceiveVideo : 1
});
})
.then((offer) =>
{
let capabilities = offer.sdp;
let parsedSdp = sdpTransform.parse(capabilities);
logger.debug('capabilities [parsed:%O, sdp:%s]', parsedSdp, capabilities);
// Accept the protoo request.
accept(
{
capabilities : capabilities,
usePlanB : utils.isPlanB()
});
})
.then(() =>
{
logger.debug('"joinme" request accepted');
// Emit 'join' event.
this.emit('join');
})
.catch((error) =>
{
logger.error('"joinme" request failed: %o', error);
reject(500, error.message);
throw error;
});
break;
}
case 'peers':
{
this.emit('peers', request.data.peers);
accept();
break;
}
case 'addpeer':
{
this.emit('addpeer', request.data.peer);
accept();
break;
}
case 'updatepeer':
{
this.emit('updatepeer', request.data.peer);
accept();
break;
}
case 'removepeer':
{
this.emit('removepeer', request.data.peer);
accept();
break;
}
case 'offer':
{
let offer = new RTCSessionDescription(request.data.offer);
let parsedSdp = sdpTransform.parse(offer.sdp);
logger.debug('received offer [parsed:%O, sdp:%s]', parsedSdp, offer.sdp);
Promise.resolve()
.then(() =>
{
return this._peerconnection.setRemoteDescription(offer);
})
.then(() =>
{
return this._peerconnection.createAnswer();
})
// Play with simulcast.
.then((answer) =>
{
if (!ENABLE_SIMULCAST)
return answer;
// Chrome Plan B simulcast.
if (utils.isPlanB())
{
// Just for the initial offer.
// NOTE: Otherwise Chrome crashes.
// TODO: This prevents simulcast to be applied to new tracks.
if (this._peerconnection.localDescription && this._peerconnection.localDescription.sdp)
return answer;
// TODO: Should be done just for VP8.
let parsedSdp = sdpTransform.parse(answer.sdp);
let videoMedia;
for (let m of parsedSdp.media)
{
if (m.type === 'video')
{
videoMedia = m;
break;
}
}
if (!videoMedia || !videoMedia.ssrcs)
return answer;
logger.debug('setting video simulcast (PlanB)');
let ssrc1;
let ssrc2;
let ssrc3;
let cname;
let msid;
for (let ssrcObj of videoMedia.ssrcs)
{
// Chrome uses:
// a=ssrc:xxxx msid:yyyy zzzz
// a=ssrc:xxxx mslabel:yyyy
// a=ssrc:xxxx label:zzzz
// Where yyyy is the MediaStream.id and zzzz the MediaStreamTrack.id.
switch (ssrcObj.attribute)
{
case 'cname':
ssrc1 = ssrcObj.id;
cname = ssrcObj.value;
break;
case 'msid':
msid = ssrcObj.value;
break;
}
}
ssrc2 = ssrc1 + 1;
ssrc3 = ssrc1 + 2;
videoMedia.ssrcGroups =
[
{
semantics : 'SIM',
ssrcs : `${ssrc1} ${ssrc2} ${ssrc3}`
}
];
videoMedia.ssrcs =
[
{
id : ssrc1,
attribute : 'cname',
value : cname,
},
{
id : ssrc1,
attribute : 'msid',
value : msid,
},
{
id : ssrc2,
attribute : 'cname',
value : cname,
},
{
id : ssrc2,
attribute : 'msid',
value : msid,
},
{
id : ssrc3,
attribute : 'cname',
value : cname,
},
{
id : ssrc3,
attribute : 'msid',
value : msid,
}
];
let modifiedAnswer =
{
type : 'answer',
sdp : sdpTransform.write(parsedSdp)
};
return modifiedAnswer;
}
// Firefox way.
else
{
let parsedSdp = sdpTransform.parse(answer.sdp);
let videoMedia;
logger.debug('created answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
for (let m of parsedSdp.media)
{
if (m.type === 'video' && m.direction === 'sendonly')
{
videoMedia = m;
break;
}
}
if (!videoMedia)
return answer;
logger.debug('setting video simulcast (Unified-Plan)');
videoMedia.simulcast_03 =
{
value : 'send rid=1,2'
};
videoMedia.rids =
[
{ id: '1', direction: 'send' },
{ id: '2', direction: 'send' }
];
let modifiedAnswer =
{
type : 'answer',
sdp : sdpTransform.write(parsedSdp)
};
return modifiedAnswer;
}
})
.then((answer) =>
{
return this._peerconnection.setLocalDescription(answer);
})
.then(() =>
{
let answer = this._peerconnection.localDescription;
let parsedSdp = sdpTransform.parse(answer.sdp);
logger.debug('sent answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
accept(
{
answer :
{
type : answer.type,
sdp : answer.sdp
}
});
})
.catch((error) =>
{
logger.error('"offer" request failed: %o', error);
reject(500, error.message);
throw error;
})
.then(() =>
{
// If Firefox trigger 'forcestreamsupdate' event due to bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
if (browser.firefox || browser.gecko)
{
// Not sure, but it thinks that the timeout does the trick.
setTimeout(() => this.emit('forcestreamsupdate'), 500);
}
});
break;
}
default:
{
logger.error('unknown method');
reject(404, 'unknown method');
}
}
}
_updateWebcams()
{
logger.debug('_updateWebcams()');
// Reset the list.
this._webcams = new Map();
return Promise.resolve()
.then(() =>
{
return navigator.mediaDevices.enumerateDevices();
})
.then((devices) =>
{
for (let device of devices)
{
if (device.kind !== 'videoinput')
continue;
this._webcams.set(device.deviceId, device);
}
})
.then(() =>
{
let array = Array.from(this._webcams.values());
let len = array.length;
let currentWebcamId = this._webcam ? this._webcam.deviceId : undefined;
logger.debug('_updateWebcams() [webcams:%o]', array);
if (len === 0)
this._webcam = null;
else if (!this._webcams.has(currentWebcamId))
this._webcam = array[0];
this.emit('numwebcams', len);
this._emitWebcamType();
});
}
_getLocalStream(constraints)
{
logger.debug('_getLocalStream() [constraints:%o, webcam:%o]',
constraints, this._webcam);
if (this._webcam)
constraints.video.deviceId = { exact: this._webcam.deviceId };
return navigator.mediaDevices.getUserMedia(constraints);
}
_createPeerConnection()
{
logger.debug('_createPeerConnection()');
this._peerconnection = new RTCPeerConnection({ iceServers: [] });
// TODO: TMP
global.CLIENT = this;
global.PC = this._peerconnection;
if (this._localStream)
this._peerconnection.addStream(this._localStream);
this._peerconnection.addEventListener('iceconnectionstatechange', () =>
{
let state = this._peerconnection.iceConnectionState;
logger.debug('peerconnection "iceconnectionstatechange" event [state:%s]', state);
this.emit('connectionstate', state);
});
this._peerconnection.addEventListener('addstream', (event) =>
{
let stream = event.stream;
logger.debug('peerconnection "addstream" event [stream:%o]', stream);
this.emit('addstream', stream);
// NOTE: For testing.
let interval = setInterval(() =>
{
if (!stream.active)
{
logger.warn('stream inactive [stream:%o]', stream);
clearInterval(interval);
}
}, 2000);
stream.addEventListener('addtrack', (event) =>
{
let track = event.track;
logger.debug('stream "addtrack" event [track:%o]', track);
this.emit('addtrack', track);
// Firefox does not implement 'stream.onremovetrack' so let's use 'track.ended'.
// But... track "ended" is neither fired.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
track.addEventListener('ended', () =>
{
logger.debug('track "ended" event [track:%o]', track);
this.emit('removetrack', track);
});
});
// NOTE: Not implemented in Firefox.
stream.addEventListener('removetrack', (event) =>
{
let track = event.track;
logger.debug('stream "removetrack" event [track:%o]', track);
this.emit('removetrack', track);
});
});
this._peerconnection.addEventListener('removestream', (event) =>
{
let stream = event.stream;
logger.debug('peerconnection "removestream" event [stream:%o]', stream);
this.emit('removestream', stream);
});
}
_requestRenegotiation()
{
logger.debug('_requestRenegotiation()');
return this._protooPeer.send('reofferme');
}
_restartIce()
{
logger.debug('_restartIce()');
return this._protooPeer.send('restartice')
.then(() =>
{
logger.debug('_restartIce() succeded');
})
.catch((error) =>
{
logger.error('_restartIce() failed: %o', error);
throw error;
});
}
_emitWebcamType()
{
let webcam = this._webcam;
if (!webcam)
return;
if (/(back|rear)/i.test(webcam.label))
{
logger.debug('_emitWebcamType() | it seems to be a back camera');
this.emit('webcamtype', 'back');
}
else
{
logger.debug('_emitWebcamType() | it seems to be a front camera');
this.emit('webcamtype', 'front');
}
}
}
+43
View File
@@ -0,0 +1,43 @@
'use strict';
import debug from 'debug';
const APP_NAME = 'mediasoup-demo';
export default class Logger
{
constructor(prefix)
{
if (prefix)
{
this._debug = debug(APP_NAME + ':' + prefix);
this._warn = debug(APP_NAME + ':WARN:' + prefix);
this._error = debug(APP_NAME + ':ERROR:' + prefix);
}
else
{
this._debug = debug(APP_NAME);
this._warn = debug(APP_NAME + ':WARN');
this._error = debug(APP_NAME + ':ERROR');
}
this._debug.log = console.info.bind(console);
this._warn.log = console.warn.bind(console);
this._error.log = console.error.bind(console);
}
get debug()
{
return this._debug;
}
get warn()
{
return this._warn;
}
get error()
{
return this._error;
}
}
+56
View File
@@ -0,0 +1,56 @@
'use strict';
import React from 'react';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import Logger from '../Logger';
import muiTheme from './muiTheme';
import Notifier from './Notifier';
import Room from './Room';
const logger = new Logger('App'); // eslint-disable-line no-unused-vars
export default class App extends React.Component
{
constructor()
{
super();
this.state = {};
}
render()
{
let props = this.props;
return (
<MuiThemeProvider muiTheme={muiTheme}>
<div data-component='App'>
<Notifier ref='Notifier'/>
<Room
peerId={props.peerId}
roomId={props.roomId}
onNotify={this.handleNotify.bind(this)}
onHideNotification={this.handleHideNotification.bind(this)}
/>
</div>
</MuiThemeProvider>
);
}
handleNotify(data)
{
this.refs.Notifier.notify(data);
}
handleHideNotification(uid)
{
this.refs.Notifier.hideNotification(uid);
}
}
App.propTypes =
{
peerId : React.PropTypes.string.isRequired,
roomId : React.PropTypes.string.isRequired
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
import React from 'react';
import IconButton from 'material-ui/IconButton/IconButton';
import MicOffIcon from 'material-ui/svg-icons/av/mic-off';
import VideoCamOffIcon from 'material-ui/svg-icons/av/videocam-off';
import ChangeVideoCamIcon from 'material-ui/svg-icons/av/repeat';
import Video from './Video';
import Logger from '../Logger';
const logger = new Logger('LocalVideo'); // eslint-disable-line no-unused-vars
export default class LocalVideo extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
micMuted : false,
webcam : props.stream && !!props.stream.getVideoTracks()[0],
togglingWebcam : false
};
}
render()
{
let props = this.props;
let state = this.state;
return (
<div data-component='LocalVideo' className={`state-${props.connectionState}`}>
{props.stream ?
<Video
stream={props.stream}
resolution={props.resolution}
muted
mirror={props.webcamType === 'front'}
onResolutionChange={this.handleResolutionChange.bind(this)}
/>
:null}
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteMic.bind(this)}
>
<MicOffIcon
color={!state.micMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
disabled={state.togglingWebcam}
onClick={this.handleClickWebcam.bind(this)}
>
<VideoCamOffIcon
color={state.webcam ? '#fff' : '#ff8a00'}
/>
</IconButton>
{props.multipleWebcams ?
<IconButton
className='control'
disabled={!state.webcam || state.togglingWebcam}
onClick={this.handleClickChangeWebcam.bind(this)}
>
<ChangeVideoCamIcon
color='#fff'
/>
</IconButton>
:null}
</div>
<div className='info'>
<div className='peer-id'>{props.peerId}</div>
</div>
</div>
);
}
componentWillReceiveProps(nextProps)
{
this.setState({ webcam: nextProps.stream && !!nextProps.stream.getVideoTracks()[0] });
}
handleClickMuteMic()
{
logger.debug('handleClickMuteMic()');
let value = !this.state.micMuted;
this.props.onMicMute(value)
.then(() =>
{
this.setState({ micMuted: value });
});
}
handleClickWebcam()
{
logger.debug('handleClickWebcam()');
let value = !this.state.webcam;
this.setState({ togglingWebcam: true });
this.props.onWebcamToggle(value)
.then(() =>
{
this.setState({ webcam: value, togglingWebcam: false });
})
.catch(() =>
{
this.setState({ togglingWebcam: false });
});
}
handleClickChangeWebcam()
{
logger.debug('handleClickChangeWebcam()');
this.props.onWebcamChange();
}
handleResolutionChange()
{
logger.debug('handleResolutionChange()');
this.props.onResolutionChange();
}
}
LocalVideo.propTypes =
{
peerId : React.PropTypes.string.isRequired,
stream : React.PropTypes.object,
resolution : React.PropTypes.string,
multipleWebcams : React.PropTypes.bool.isRequired,
webcamType : React.PropTypes.string,
connectionState : React.PropTypes.string,
onMicMute : React.PropTypes.func.isRequired,
onWebcamToggle : React.PropTypes.func.isRequired,
onWebcamChange : React.PropTypes.func.isRequired,
onResolutionChange : React.PropTypes.func.isRequired
};
+151
View File
@@ -0,0 +1,151 @@
'use strict';
import React from 'react';
import NotificationSystem from 'react-notification-system';
const STYLE =
{
NotificationItem :
{
DefaultStyle :
{
padding : '6px 10px',
backgroundColor : 'rgba(255,255,255, 0.9)',
fontFamily : 'Roboto',
fontWeight : 400,
fontSize : '1rem',
cursor : 'default',
WebkitUserSelect : 'none',
MozUserSelect : 'none',
userSelect : 'none',
transition : '0.15s ease-in-out'
},
info :
{
color : '#000',
borderTop : '2px solid rgba(255,0,78, 0.75)'
},
success :
{
color : '#000',
borderTop : '4px solid rgba(73,206,62, 0.75)'
},
error :
{
color : '#000',
borderTop : '4px solid #ff0014'
}
},
Title :
{
DefaultStyle :
{
margin : '0 0 8px 0',
fontFamily : 'Roboto',
fontWeight : 500,
fontSize : '1.1rem',
userSelect : 'none',
WebkitUserSelect : 'none',
MozUserSelect : 'none'
},
info :
{
color : 'rgba(255,0,78, 0.85)'
},
success :
{
color : 'rgba(73,206,62, 0.9)'
},
error :
{
color : '#ff0014'
}
},
Dismiss :
{
DefaultStyle :
{
display : 'none'
}
},
Action :
{
DefaultStyle :
{
padding : '8px 24px',
fontSize : '1.2rem',
cursor : 'pointer',
userSelect : 'none',
WebkitUserSelect : 'none',
MozUserSelect : 'none'
},
info :
{
backgroundColor : 'rgba(255,0,78, 1)'
},
success :
{
backgroundColor : 'rgba(73,206,62, 0.75)'
}
}
};
export default class Notifier extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
return (
<NotificationSystem ref='NotificationSystem' style={STYLE} allowHTML={false}/>
);
}
notify(data)
{
let data2;
switch (data.level)
{
case 'info' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 1
}, data);
break;
case 'success' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 1
}, data);
break;
case 'error' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 3
}, data);
break;
default:
throw new Error(`unknown level "${data.level}"`);
}
this.refs.NotificationSystem.addNotification(data2);
}
hideNotification(uid)
{
this.refs.NotificationSystem.removeNotification(uid);
}
}
+101
View File
@@ -0,0 +1,101 @@
'use strict';
import React from 'react';
import IconButton from 'material-ui/IconButton/IconButton';
import VolumeOffIcon from 'material-ui/svg-icons/av/volume-off';
import VideoOffIcon from 'material-ui/svg-icons/av/videocam-off';
import classnames from 'classnames';
import Video from './Video';
import Logger from '../Logger';
const logger = new Logger('RemoteVideo'); // eslint-disable-line no-unused-vars
export default class RemoteVideo extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
audioMuted : false
};
}
render()
{
let props = this.props;
let state = this.state;
let hasVideo = !!props.stream.getVideoTracks()[0];
global.SS = props.stream;
return (
<div
data-component='RemoteVideo'
className={classnames({ fullsize: !!props.fullsize })}
>
<Video
stream={props.stream}
muted={state.audioMuted}
/>
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteAudio.bind(this)}
>
<VolumeOffIcon
color={!state.audioMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
onClick={this.handleClickDisableVideo.bind(this)}
>
<VideoOffIcon
color={hasVideo ? '#fff' : '#ff8a00'}
/>
</IconButton>
</div>
<div className='info'>
<div className='peer-id'>{props.peer.id}</div>
</div>
</div>
);
}
handleClickMuteAudio()
{
logger.debug('handleClickMuteAudio()');
let value = !this.state.audioMuted;
this.setState({ audioMuted: value });
}
handleClickDisableVideo()
{
logger.debug('handleClickDisableVideo()');
let stream = this.props.stream;
let msid = stream.id;
let hasVideo = !!stream.getVideoTracks()[0];
if (hasVideo)
this.props.onDisableVideo(msid);
else
this.props.onEnableVideo(msid);
}
}
RemoteVideo.propTypes =
{
peer : React.PropTypes.object.isRequired,
stream : React.PropTypes.object.isRequired,
fullsize : React.PropTypes.bool,
onDisableVideo : React.PropTypes.func.isRequired,
onEnableVideo : React.PropTypes.func.isRequired
};
+487
View File
@@ -0,0 +1,487 @@
'use strict';
import React from 'react';
import ClipboardButton from 'react-clipboard.js';
import TransitionAppear from './TransitionAppear';
import LocalVideo from './LocalVideo';
import RemoteVideo from './RemoteVideo';
import Stats from './Stats';
import Logger from '../Logger';
import utils from '../utils';
import Client from '../Client';
const logger = new Logger('Room');
const STATS_INTERVAL = 1000;
export default class Room extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
peers : {},
localStream : null,
localVideoResolution : null, // qvga / vga / hd / fullhd.
multipleWebcams : false,
webcamType : null,
connectionState : null,
remoteStreams : {},
showStats : false,
stats : null
};
// Mounted flag
this._mounted = false;
// Client instance
this._client = null;
// Timer to retrieve RTC stats.
this._statsTimer = null;
}
render()
{
let props = this.props;
let state = this.state;
let numPeers = Object.keys(state.remoteStreams).length;
return (
<TransitionAppear duration={2000}>
<div data-component='Room'>
<div className='room-link-wrapper'>
<div className='room-link'>
<ClipboardButton
component='a'
className='link'
button-href={window.location.href}
data-clipboard-text={window.location.href}
onSuccess={this.handleRoomLinkCopied.bind(this)}
onClick={() => {}} // Avoid link action.
>
invite people to this room
</ClipboardButton>
</div>
</div>
<div className='remote-videos'>
{
Object.keys(state.remoteStreams).map((msid) =>
{
let stream = state.remoteStreams[msid];
let peer;
for (let peerId of Object.keys(state.peers))
{
peer = state.peers[peerId];
if (peer.msids.indexOf(msid) !== -1)
break;
}
if (!peer)
return;
return (
<TransitionAppear key={msid} duration={500}>
<RemoteVideo
peer={peer}
stream={stream}
fullsize={numPeers === 1}
onDisableVideo={this.handleDisableRemoteVideo.bind(this)}
onEnableVideo={this.handleEnableRemoteVideo.bind(this)}
/>
</TransitionAppear>
);
})
}
</div>
<TransitionAppear duration={500}>
<div className='local-video'>
<LocalVideo
peerId={props.peerId}
stream={state.localStream}
resolution={state.localVideoResolution}
multipleWebcams={state.multipleWebcams}
webcamType={state.webcamType}
connectionState={state.connectionState}
onMicMute={this.handleLocalMute.bind(this)}
onWebcamToggle={this.handleLocalWebcamToggle.bind(this)}
onWebcamChange={this.handleLocalWebcamChange.bind(this)}
onResolutionChange={this.handleLocalResolutionChange.bind(this)}
/>
{state.showStats ?
<TransitionAppear duration={500}>
<Stats
stats={state.stats || new Map()}
onClose={this.handleStatsClose.bind(this)}
/>
</TransitionAppear>
:
<div
className='show-stats'
onClick={this.handleClickShowStats.bind(this)}
/>
}
</div>
</TransitionAppear>
</div>
</TransitionAppear>
);
}
componentDidMount()
{
// Set flag
this._mounted = true;
// Run the client
this._runClient();
}
componentWillUnmount()
{
let state = this.state;
// Unset flag
this._mounted = false;
// Close client
this._client.removeAllListeners();
this._client.close();
// Close local MediaStream
if (state.localStream)
utils.closeMediaStream(state.localStream);
}
handleRoomLinkCopied()
{
logger.debug('handleRoomLinkCopied()');
this.props.onNotify(
{
level : 'success',
position : 'tr',
title : 'Room URL copied to the clipboard',
message : 'Share it with others to join this room'
});
}
handleLocalMute(value)
{
logger.debug('handleLocalMute() [value:%s]', value);
let micTrack = this.state.localStream.getAudioTracks()[0];
if (!micTrack)
return Promise.reject(new Error('no audio track'));
micTrack.enabled = !value;
return Promise.resolve();
}
handleLocalWebcamToggle(value)
{
logger.debug('handleLocalWebcamToggle() [value:%s]', value);
return Promise.resolve()
.then(() =>
{
if (value)
return this._client.addVideo();
else
return this._client.removeVideo();
})
.then(() =>
{
let localStream = this.state.localStream;
this.setState({ localStream });
});
}
handleLocalWebcamChange()
{
logger.debug('handleLocalWebcamChange()');
this._client.changeWebcam();
}
handleLocalResolutionChange()
{
logger.debug('handleLocalResolutionChange()');
this._client.changeVideoResolution();
}
handleStatsClose()
{
logger.debug('handleStatsClose()');
this.setState({ showStats: false });
this._stopStats();
}
handleClickShowStats()
{
logger.debug('handleClickShowStats()');
this.setState({ showStats: true });
this._startStats();
}
handleDisableRemoteVideo(msid)
{
logger.debug('handleDisableRemoteVideo() [msid:"%s"]', msid);
this._client.disableRemoteVideo(msid);
}
handleEnableRemoteVideo(msid)
{
logger.debug('handleEnableRemoteVideo() [msid:"%s"]', msid);
this._client.enableRemoteVideo(msid);
}
_runClient()
{
let peerId = this.props.peerId;
let roomId = this.props.roomId;
logger.debug('_runClient() [peerId:"%s", roomId:"%s"]', peerId, roomId);
this._client = new Client(peerId, roomId);
this._client.on('localstream', (stream, resolution) =>
{
this.setState(
{
localStream : stream,
localVideoResolution : resolution
});
});
this._client.on('join', () =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
this.props.onNotify(
{
level : 'success',
title : 'Yes!',
message : 'You are in the room!',
image : '/resources/images/room.svg',
imageWidth : 80,
imageHeight : 80
});
// Start retrieving WebRTC stats (unless mobile)
if (utils.isDesktop())
{
this.setState({ showStats: true });
setTimeout(() =>
{
this._startStats();
}, STATS_INTERVAL / 2);
}
});
this._client.on('close', (error) =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
if (error)
{
this.props.onNotify(
{
level : 'error',
title : 'Error',
message : error.message
});
}
// Stop retrieving WebRTC stats.
this._stopStats();
});
this._client.on('disconnected', () =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
this.props.onNotify(
{
level : 'error',
title : 'Warning',
message : 'app disconnected'
});
// Stop retrieving WebRTC stats.
this._stopStats();
});
this._client.on('numwebcams', (num) =>
{
this.setState(
{
multipleWebcams : (num > 1 ? true : false)
});
});
this._client.on('webcamtype', (type) =>
{
this.setState({ webcamType: type });
});
this._client.on('peers', (peers) =>
{
let peersObject = {};
for (let peer of peers)
{
peersObject[peer.id] = peer;
}
this.setState({ peers: peersObject });
});
this._client.on('addpeer', (peer) =>
{
this.props.onNotify(
{
level : 'success',
message : `${peer.id} joined the room`
});
let peers = this.state.peers;
peers[peer.id] = peer;
this.setState({ peers });
});
this._client.on('updatepeer', (peer) =>
{
let peers = this.state.peers;
peers[peer.id] = peer;
this.setState({ peers });
});
this._client.on('removepeer', (peer) =>
{
this.props.onNotify(
{
level : 'info',
message : `${peer.id} left the room`
});
let peers = this.state.peers;
delete peers[peer.id];
this.setState({ peers });
});
this._client.on('connectionstate', (state) =>
{
this.setState({ connectionState: state });
});
this._client.on('addstream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
remoteStreams[stream.id] = stream;
this.setState({ remoteStreams });
});
this._client.on('removestream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
delete remoteStreams[stream.id];
this.setState({ remoteStreams });
});
this._client.on('addtrack', () =>
{
let remoteStreams = this.state.remoteStreams;
this.setState({ remoteStreams });
});
this._client.on('removetrack', () =>
{
let remoteStreams = this.state.remoteStreams;
this.setState({ remoteStreams });
});
this._client.on('forcestreamsupdate', () =>
{
// Just firef for Firefox due to bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this.forceUpdate();
});
}
_startStats()
{
logger.debug('_startStats()');
getStats.call(this);
function getStats()
{
this._client.getStats()
.then((stats) =>
{
if (!this._mounted)
return;
this.setState({ stats });
this._statsTimer = setTimeout(() =>
{
getStats.call(this);
}, STATS_INTERVAL);
})
.catch((error) =>
{
logger.error('getStats() failed: %o', error);
this.setState({ stats: null });
this._statsTimer = setTimeout(() =>
{
getStats.call(this);
}, STATS_INTERVAL);
});
}
}
_stopStats()
{
logger.debug('_stopStats()');
this.setState({ stats: null });
clearTimeout(this._statsTimer);
}
}
Room.propTypes =
{
peerId : React.PropTypes.string.isRequired,
roomId : React.PropTypes.string.isRequired,
onNotify : React.PropTypes.func.isRequired,
onHideNotification : React.PropTypes.func.isRequired
};
+497
View File
@@ -0,0 +1,497 @@
'use strict';
import React from 'react';
import browser from 'bowser';
import Logger from '../Logger';
const logger = new Logger('Stats'); // eslint-disable-line no-unused-vars
// TODO: TMP
global.BROWSER = browser;
export default class Stats extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
stats :
{
transport : null,
audio : null,
video : null
}
};
}
componentWillMount()
{
let stats = this.props.stats;
this._processStats(stats);
}
componentWillReceiveProps(nextProps)
{
let stats = nextProps.stats;
this._processStats(stats);
}
render()
{
let state = this.state;
return (
<div data-component='Stats'>
<div
className='close'
onClick={this.handleCloseClick.bind(this)}
/>
{
Object.keys(state.stats).map((blockName) =>
{
let block = state.stats[blockName];
if (!block)
return;
let items = Object.keys(block).map((itemName) =>
{
let value = block[itemName];
if (value === undefined)
return;
return (
<div key={itemName} className='item'>
<div className='key'>{itemName}</div>
<div className='value'>{value}</div>
</div>
);
});
if (!items.length)
return null;
return (
<div key={blockName} className='block'>
<h1>{blockName}</h1>
{items}
</div>
);
})
}
</div>
);
}
handleCloseClick()
{
this.props.onClose();
}
_processStats(stats)
{
if (browser.check({ chrome: '58' }, true))
{
this._processStatsChrome58(stats);
}
else if (browser.check({ chrome: '40' }, true))
{
this._processStatsChromeOld(stats);
}
else if (browser.check({ firefox: '40' }, true))
{
this._processStatsFirefox(stats);
}
else
{
logger.warn('_processStats() | unsupported browser [name:"%s", version:%s]',
browser.name, browser.version);
}
}
_processStatsChrome58(stats)
{
let transport = {};
let audio = {};
let video = {};
let selectedCandidatePair = null;
let localCandidates = {};
let remoteCandidates = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'transport':
{
transport['bytes sent'] = group.bytesSent;
transport['bytes received'] = group.bytesReceived;
break;
}
case 'candidate-pair':
{
if (!group.writable)
break;
selectedCandidatePair = group;
transport['available bitrate'] =
Math.round(group.availableOutgoingBitrate / 1000) + ' kbps';
transport['current RTT'] =
Math.round(group.currentRoundTripTime * 1000) + ' ms';
break;
}
case 'local-candidate':
{
localCandidates[group.id] = group;
break;
}
case 'remote-candidate':
{
remoteCandidates[group.id] = group;
break;
}
case 'codec':
{
let mimeType = group.mimeType.split('/');
let kind = mimeType[0];
let codec = mimeType[1];
let block;
switch (kind)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['codec'] = codec;
block['payload type'] = group.payloadType;
break;
}
case 'track':
{
if (group.kind !== 'video')
break;
video['frame size'] = group.frameWidth + ' x ' + group.frameHeight;
video['frames sent'] = group.framesSent;
break;
}
case 'outbound-rtp':
{
if (group.isRemote)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
if (block === video)
block['frames encoded'] = group.framesEncoded;
block['NACK count'] = group.nackCount;
block['PLI count'] = group.pliCount;
block['FIR count'] = group.firCount;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
if (selectedCandidatePair)
{
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
transport['protocol'] = localCandidate.protocol;
transport['local IP'] = localCandidate.ip;
transport['local port'] = localCandidate.port;
transport['remote IP'] = remoteCandidate.ip;
transport['remote port'] = remoteCandidate.port;
}
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
_processStatsChromeOld(stats)
{
let transport = {};
let audio = {};
let video = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'googCandidatePair':
{
if (group.googActiveConnection !== 'true')
break;
let localAddress = group.googLocalAddress.split(':');
let remoteAddress = group.googRemoteAddress.split(':');
let localIP = localAddress[0];
let localPort = localAddress[1];
let remoteIP = remoteAddress[0];
let remotePort = remoteAddress[1];
transport['protocol'] = group.googTransportType;
transport['local IP'] = localIP;
transport['local port'] = localPort;
transport['remote IP'] = remoteIP;
transport['remote port'] = remotePort;
transport['bytes sent'] = group.bytesSent;
transport['bytes received'] = group.bytesReceived;
transport['RTT'] = Math.round(group.googRtt) + ' ms';
break;
}
case 'VideoBwe':
{
transport['available bitrate'] =
Math.round(group.googAvailableSendBandwidth / 1000) + ' kbps';
transport['transmit bitrate'] =
Math.round(group.googTransmitBitrate / 1000) + ' kbps';
break;
}
case 'ssrc':
{
if (group.packetsSent === undefined)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['codec'] = group.googCodecName;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
block['packets lost'] = group.packetsLost;
if (block === video)
{
block['frames encoded'] = group.framesEncoded;
video['frame size'] =
group.googFrameWidthSent + ' x ' + group.googFrameHeightSent;
video['frame rate'] = group.googFrameRateSent;
}
block['NACK count'] = group.googNacksReceived;
block['PLI count'] = group.googPlisReceived;
block['FIR count'] = group.googFirsReceived;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
_processStatsFirefox(stats)
{
let transport = {};
let audio = {};
let video = {};
let selectedCandidatePair = null;
let localCandidates = {};
let remoteCandidates = {};
for (let group of stats.values())
{
// TODO: REMOVE
global.STATS = stats;
switch (group.type)
{
case 'candidate-pair':
{
if (!group.selected)
break;
selectedCandidatePair = group;
break;
}
case 'local-candidate':
{
localCandidates[group.id] = group;
break;
}
case 'remote-candidate':
{
remoteCandidates[group.id] = group;
break;
}
case 'outbound-rtp':
{
if (group.isRemote)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
if (block === video)
{
block['bitrate'] =
Math.round(group.bitrateMean / 1000) + ' kbps';
block['frames encoded'] = group.framesEncoded;
video['frame rate'] = Math.round(group.framerateMean);
}
block['NACK count'] = group.nackCount;
block['PLI count'] = group.pliCount;
block['FIR count'] = group.firCount;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
if (selectedCandidatePair)
{
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
transport['protocol'] = localCandidate.transport;
transport['local IP'] = localCandidate.ipAddress;
transport['local port'] = localCandidate.portNumber;
transport['remote IP'] = remoteCandidate.ipAddress;
transport['remote port'] = remoteCandidate.portNumber;
}
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
}
Stats.propTypes =
{
stats : React.PropTypes.object.isRequired,
onClose : React.PropTypes.func.isRequired
};
+59
View File
@@ -0,0 +1,59 @@
'use strict';
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
const DEFAULT_DURATION = 1000;
export default class TransitionAppear extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
let props = this.props;
let duration = props.hasOwnProperty('duration') ? props.duration : DEFAULT_DURATION;
return (
<ReactCSSTransitionGroup
component={FakeTransitionWrapper}
transitionName='transition'
transitionAppear={!!duration}
transitionAppearTimeout={duration}
transitionEnter={false}
transitionLeave={false}
>
{this.props.children}
</ReactCSSTransitionGroup>
);
}
}
TransitionAppear.propTypes =
{
children : React.PropTypes.any,
duration : React.PropTypes.number
};
class FakeTransitionWrapper extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
let children = React.Children.toArray(this.props.children);
return children[0] || null;
}
}
FakeTransitionWrapper.propTypes =
{
children : React.PropTypes.any
};
+233
View File
@@ -0,0 +1,233 @@
'use strict';
import React from 'react';
import Logger from '../Logger';
import classnames from 'classnames';
import hark from 'hark';
const logger = new Logger('Video'); // eslint-disable-line no-unused-vars
export default class Video extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
width : 0,
height : 0,
resolution : null,
volume : 0 // Integer from 0 to 10.
};
let stream = props.stream;
// Clean stream.
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this._cleanStream(stream);
// Current MediaStreamTracks info.
this._tracksHash = this._getTracksHash(stream);
// Periodic timer to show video dimensions.
this._videoResolutionTimer = null;
// Hark instance.
this._hark = null;
}
render()
{
let props = this.props;
let state = this.state;
return (
<div data-component='Video'>
{state.width ?
(
<div
className={classnames('resolution', { clickable: !!props.resolution })}
onClick={this.handleResolutionClick.bind(this)}
>
<p>{state.width}x{state.height}</p>
{props.resolution ?
<p>{props.resolution}</p>
:null}
</div>
)
:null}
<div className='volume'>
<div className={classnames('bar', `level${state.volume}`)}/>
</div>
<video
ref='video'
className={classnames({ mirror: props.mirror })}
autoPlay
muted={props.muted}
/>
</div>
);
}
componentDidMount()
{
let stream = this.props.stream;
let video = this.refs.video;
video.srcObject = stream;
this._showVideoResolution();
this._videoResolutionTimer = setInterval(() =>
{
this._showVideoResolution();
}, 500);
if (stream.getAudioTracks().length > 0)
{
this._hark = hark(stream);
this._hark.on('speaking', () =>
{
logger.debug('hark "speaking" event');
});
this._hark.on('stopped_speaking', () =>
{
logger.debug('hark "stopped_speaking" event');
this.setState({ volume: 0 });
});
this._hark.on('volume_change', (volume, threshold) =>
{
if (volume < threshold)
return;
// logger.debug('hark "volume_change" event [volume:%sdB, threshold:%sdB]', volume, threshold);
this.setState(
{
volume : Math.round((volume - threshold) * (-10) / threshold)
});
});
}
}
componentWillUnmount()
{
clearInterval(this._videoResolutionTimer);
if (this._hark)
this._hark.stop();
}
componentWillReceiveProps(nextProps)
{
let stream = nextProps.stream;
// Clean stream.
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this._cleanStream(stream);
// If there is something different in the stream, re-render it.
let previousTracksHash = this._tracksHash;
this._tracksHash = this._getTracksHash(stream);
if (this._tracksHash !== previousTracksHash)
this.refs.video.srcObject = stream;
}
handleResolutionClick()
{
if (!this.props.resolution)
return;
logger.debug('handleResolutionClick()');
this.props.onResolutionChange();
}
_getTracksHash(stream)
{
return stream.getTracks()
.map((track) =>
{
return track.id;
})
.join('|');
}
_showVideoResolution()
{
let video = this.refs.video;
this.setState(
{
width : video.videoWidth,
height : video.videoHeight
});
}
_cleanStream(stream)
{
// Hack for Firefox bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
if (!stream)
return;
let tracks = stream.getTracks();
let previousNumTracks = tracks.length;
// Remove ended tracks.
for (let track of tracks)
{
if (track.readyState === 'ended')
{
logger.warn('_cleanStream() | removing ended track [track:%o]', track);
stream.removeTrack(track);
}
}
// If there are multiple live audio tracks (related to the bug?) just keep
// the last one.
while (stream.getAudioTracks().length > 1)
{
let track = stream.getAudioTracks()[0];
logger.warn('_cleanStream() | removing live audio track due the presence of others [track:%o]', track);
stream.removeTrack(track);
}
// If there are multiple live video tracks (related to the bug?) just keep
// the last one.
while (stream.getVideoTracks().length > 1)
{
let track = stream.getVideoTracks()[0];
logger.warn('_cleanStream() | removing live video track due the presence of others [track:%o]', track);
stream.removeTrack(track);
}
let numTracks = stream.getTracks().length;
if (numTracks !== previousNumTracks)
logger.warn('_cleanStream() | num tracks changed from %s to %s', previousNumTracks, numTracks);
}
}
Video.propTypes =
{
stream : React.PropTypes.object.isRequired,
resolution : React.PropTypes.string,
muted : React.PropTypes.bool,
mirror : React.PropTypes.bool,
onResolutionChange : React.PropTypes.func
};
+16
View File
@@ -0,0 +1,16 @@
'use strict';
import getMuiTheme from 'material-ui/styles/getMuiTheme';
import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
import { grey500 } from 'material-ui/styles/colors';
// NOTE: I should clone it
let theme = lightBaseTheme;
theme.palette.borderColor = grey500;
let muiTheme = getMuiTheme(lightBaseTheme);
export default muiTheme;
+56
View File
@@ -0,0 +1,56 @@
'use strict';
import browser from 'bowser';
import webrtc from 'webrtc-adapter'; // eslint-disable-line no-unused-vars
import domready from 'domready';
import UrlParse from 'url-parse';
import React from 'react';
import ReactDOM from 'react-dom';
import injectTapEventPlugin from 'react-tap-event-plugin';
import randomString from 'random-string';
import Logger from './Logger';
import utils from './utils';
import App from './components/App';
const REGEXP_FRAGMENT_ROOM_ID = new RegExp('^#room-id=([0-9a-zA-Z]+)$');
const logger = new Logger();
logger.debug('detected browser [name:"%s", version:%s]', browser.name, browser.version);
injectTapEventPlugin();
domready(() =>
{
logger.debug('DOM ready');
// Load stuff and run
utils.initialize()
.then(run)
.catch((error) =>
{
console.error(error);
});
});
function run()
{
logger.debug('run() [environment:%s]', process.env.NODE_ENV);
let container = document.getElementById('mediasoup-demo-app-container');
let urlParser = new UrlParse(window.location.href, true);
let match = urlParser.hash.match(REGEXP_FRAGMENT_ROOM_ID);
let peerId = randomString({ length: 8 }).toLowerCase();
let roomId;
if (match)
{
roomId = match[1];
}
else
{
roomId = randomString({ length: 8 }).toLowerCase();
window.location = `#room-id=${roomId}`;
}
ReactDOM.render(<App peerId={peerId} roomId={roomId}/>, container);
}
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const config = require('../config');
module.exports =
{
getProtooUrl(peerId, roomId)
{
let hostname = window.location.hostname;
let port = config.protoo.listenPort;
let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`;
return url;
}
};
+52
View File
@@ -0,0 +1,52 @@
'use strict';
import browser from 'bowser';
import Logger from './Logger';
const logger = new Logger('utils');
let mediaQueryDetectorElem;
module.exports =
{
initialize()
{
logger.debug('initialize()');
// Media query detector stuff
mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector');
return Promise.resolve();
},
isDesktop()
{
return !!mediaQueryDetectorElem.offsetParent;
},
isMobile()
{
return !mediaQueryDetectorElem.offsetParent;
},
isPlanB()
{
if (browser.chrome || browser.chromium || browser.opera)
return true;
else
return false;
},
closeMediaStream(stream)
{
if (!stream)
return;
let tracks = stream.getTracks();
for (let i=0, len=tracks.length; i < len; i++)
{
tracks[i].stop();
}
}
};
+60
View File
@@ -0,0 +1,60 @@
{
"name": "mediasoup-demo-app",
"version": "1.0.0",
"private": true,
"description": "mediasoup demo app",
"author": "Iñaki Baz Castillo <ibc@aliax.net>",
"license": "All Rights Reserved",
"main": "lib/index.jsx",
"dependencies": {
"babel-runtime": "^6.23.0",
"bowser": "^1.6.1",
"classnames": "^2.2.5",
"debug": "^2.6.4",
"domready": "^1.0.8",
"hark": "ibc/hark#main-with-raf",
"material-ui": "^0.17.4",
"protoo-client": "^1.1.4",
"random-string": "^0.2.0",
"react": "^15.5.4",
"react-addons-css-transition-group": "^15.5.2",
"react-clipboard.js": "^1.0.1",
"react-dom": "^15.5.4",
"react-notification-system": "ibc/react-notification-system#master",
"react-tap-event-plugin": "^2.0.1",
"sdp-transform": "^2.3.0",
"url-parse": "^1.1.8",
"webrtc-adapter": "^3.3.3"
},
"devDependencies": {
"babel-plugin-transform-object-assign": "^6.22.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babelify": "^7.3.0",
"browser-sync": "^2.18.8",
"browserify": "^14.3.0",
"del": "^2.2.2",
"envify": "^4.0.0",
"eslint": "^3.19.0",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-react": "^6.10.3",
"gulp": "git://github.com/gulpjs/gulp.git#4.0",
"gulp-css-base64": "^1.3.4",
"gulp-eslint": "^3.0.1",
"gulp-header": "^1.8.8",
"gulp-if": "^2.0.2",
"gulp-plumber": "^1.1.0",
"gulp-rename": "^1.2.2",
"gulp-stylus": "^2.6.0",
"gulp-touch": "^1.0.1",
"gulp-uglify": "^2.1.2",
"gulp-util": "^3.0.8",
"mkdirp": "^0.5.1",
"ncp": "^2.0.0",
"nib": "^1.1.2",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0",
"watchify": "^3.9.0"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

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

After

Width:  |  Height:  |  Size: 2.5 KiB

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

After

Width:  |  Height:  |  Size: 265 B

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 285 B

+1
View File
@@ -0,0 +1 @@
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var n;n="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,n.antiglobal=e()}}(function(){return function e(n,o,t){function r(i,u){if(!o[i]){if(!n[i]){var l="function"==typeof require&&require;if(!u&&l)return l(i,!0);if(f)return f(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var d=o[i]={exports:{}};n[i][0].call(d.exports,function(e){var o=n[i][1][e];return r(o?o:e)},d,d.exports,e,n,o,t)}return o[i].exports}for(var f="function"==typeof require&&require,i=0;i<t.length;i++)r(t[i]);return r}({1:[function(e,n,o){(function(e){function o(){var e,n,o,u=t(),l=Array.prototype.slice.call(arguments),a=[],d=[],c=!1;for(e=0,n=u.length;e<n;e++)o=u[e],r.indexOf(o)===-1&&l.indexOf(o)===-1&&(a.push(o),c=!0);for(e=0,n=r.length;e<n;e++)o=r[e],u.indexOf(o)===-1&&(d.push(o),c=!0);if(r=u.concat(l),c){var s="antiglobal() | globals do not match:";for(e=0,n=a.length;e<n;e++)o=a[e],s=s+"\n+ "+o;for(e=0,n=d.length;e<n;e++)o=d[e],s=s+"\n- "+o;if(f&&console.error(s),i)throw new Error(s)}return!c}function t(){var n=[];for(var o in e)e.hasOwnProperty(o)&&"antiglobal"!==o&&n.push(o);return n}var r=t(),f=!0,i=!1;o.reset=function(){r=t()},Object.defineProperties(o,{log:{get:function(){return f},set:function(e){f=Boolean(e)}},throw:{get:function(){return i},set:function(e){i=Boolean(e)}}}),n.exports=o}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)});
+5
View File
@@ -0,0 +1,5 @@
[data-component='App'] {
position: relative;
min-height: 100vh;
width: 100%;
}
+96
View File
@@ -0,0 +1,96 @@
[data-component='LocalVideo'] {
position: relative;
flex: 0 0 auto;
TransitionAppear(500ms);
+desktop() {
height: 220px;
width: 220px;
border: 2px solid rgba(#fff, 0.5);
}
+mobile() {
height: 180px;
width: 180px;
border: 2px solid rgba(#fff, 0.5);
}
&.state-checking {
border-color: orange;
}
&.state-checking {
animation: LocalVideo-state-checking .75s infinite linear;
}
&.state-connected,
&.state-completed {
border-color: rgba(#49ce3e, 0.9);
}
&.state-failed,
&.state-disconnected,
&.state-closed {
border-color: rgba(#ff2000, 0.75);
}
> .controls {
pointer-events: none;
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
transition-property: opacity;
transition-duration: 0.25s;
> .control {
pointer-events: auto;
flex: 0 0 auto;
margin: 4px !important;
margin-left: 0 !important;
height: 32px !important;
width: 32px !important;
padding: 0 !important;
background-color: rgba(#000, 0.25) !important;
border-radius: 100%;
opacity: 0.8;
&:hover {
background-color: rgba(#000, 0.85) !important;
opacity: 1;
}
}
}
> .info {
position: absolute;
z-index: 10;
bottom: 4px;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
> .peer-id {
padding: 4px 14px;
font-size: 14px;
color: rgba(#fff, 0.75);
background: rgba(#000, 0.6);
border-radius: 4px;
}
}
}
@keyframes LocalVideo-state-checking {
50% {
border-color: rgba(orange, 0.9);
}
}
+110
View File
@@ -0,0 +1,110 @@
[data-component='RemoteVideo'] {
position: relative;
flex: 0 0 auto;
transition-duration: 0.25s;
TransitionAppear(500ms);
&.fullsize {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: 0;
height: 100vh;
width: 100%;
> .info {
justify-content: flex-end;
right: 4px;
}
}
+desktop() {
height: 350px;
width: 400px;
margin: 4px;
border: 4px solid rgba(#fff, 0.2);
&.fullsize {
border-width: 0;
}
}
+mobile() {
height: 50vh;
width: 100%;
border: 4px solid rgba(#fff, 0.2);
border-bottom-width: 0;
&:last-child {
border-bottom-width: 4px;
}
&.fullsize {
border-width: 0;
}
}
> .controls {
pointer-events: none;
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
transition-property: opacity;
transition-duration: 0.25s;
opacity: 0.8;
> .control {
pointer-events: auto;
flex: 0 0 auto;
margin: 4px !important;
margin-left: 0 !important;
height: 32px !important;
width: 32px !important;
padding: 0 !important;
background-color: rgba(#000, 0.25) !important;
border-radius: 100%;
opacity: 0.8;
&:hover {
background-color: rgba(#000, 0.85) !important;
opacity: 1;
}
}
}
> .info {
position: absolute;
z-index: 10;
bottom: 4px;
left: 0;
right: 0;
display: flex;
flex-direction: row;
+desktop() {
justify-content: center;
}
+mobile() {
justify-content: flex-end;
right: 4px;
}
> .peer-id {
padding: 6px 16px;
font-size: 16px;
color: rgba(#fff, 0.75);
background: rgba(#000, 0.6);
border-radius: 4px;
}
}
}
+114
View File
@@ -0,0 +1,114 @@
[data-component='Room'] {
position: relative;
overflow: auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
+desktop() {
min-height: 100vh;
width: 100%;
}
> .room-link-wrapper {
pointer-events: none;
position: absolute;
z-index: 1;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
> .room-link {
width: auto;
background-color: rgba(#fff, 0.8);
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
box-shadow: 0px 3px 12px 2px rgba(#111, 0.4);
> a.link {
display: block;;
user-select: none;
pointer-events: auto;
padding: 10px 20px;
color: #104758;
font-size: 16px;
cursor: pointer;
text-decoration: none;
transition-property: opacity;
transition-duration: 0.25s;
opacity: 0.8;
&:hover {
opacity: 1;
text-decoration: underline;
}
}
}
}
> .remote-videos {
+desktop() {
min-height: 100vh;
width: 100%;
padding-bottom: 150px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
align-content: center;
}
+mobile() {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
> .local-video {
position: fixed;
display: flex;
flex-direction: row;
z-index: 100;
box-shadow: 0px 5px 12px 2px rgba(#111, 0.5);
+desktop() {
bottom: 20px;
left: 20px;
}
+mobile() {
bottom: 10px;
left: 10px;
}
> .show-stats {
position: absolute;
bottom: 5px;
right: -40px;
width: 30px;
height: 30px;
background-image: url('/resources/images/stats.svg');
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-color: rgba(#000, 0.25);
border-radius: 4px;
cursor: pointer;
opacity: 0.85;
transition-duration: 0.25s;
&:hover {
opacity: 1;
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
[data-component='Stats'] {
TransitionAppear(500ms);
+desktop() {
position: relative;
display: flex;
flex-direction: row;
padding: 10px 0;
min-width: 100px;
background-color: rgba(#1c446f, 0.75);
font-size: 0.7rem;
}
+mobile() {
position: fixed;
z-index: 3000;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(#1c446f, 0.75);
padding: 40px 10px;
font-size: 0.9rem;
overflow: auto;
}
> .close {
background-image: url('/resources/images/close.svg');
background-position: center;
background-size: cover;
background-repeat: no-repeat;
cursor: pointer;
opacity: 0.75;
transition-duration: 0.25s;
+desktop() {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
}
+mobile() {
position: fixed;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
}
&:hover {
opacity: 1;
}
}
> .block {
padding: 5px;
+desktop() {
border-right: 1px solid rgba(#fff, 0.15);
&:last-child {
border-right: none;
}
}
+mobile() {
width: 100%;
}
> h1 {
margin-bottom: 8px;
text-align: center;
font-weight: 400;
color: #fff;
}
> .item {
display: flex;
flex-direction: row;
margin: 3px 0;
font-size: 0.95em;
font-weight: 300;
> .key {
text-align: right;
color: rgba(#fff, 0.9);
+desktop() {
width: 85px;
}
+mobile() {
width: 50%;
}
}
> .value {
margin-left: 10px;
text-align: left;
color: #aae22b;
+desktop() {
width: 85px;
}
+mobile() {
width: 50%;
}
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
[data-component='Video'] {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
> .resolution {
position: absolute;
z-index: 5;
top: 0;
left: 0;
padding: 4px 8px;
border-bottom-right-radius: 5px;
background: rgba(#000, 0.5);
transition-property: background;
transition-duration: 0.25s;
&.clickable {
cursor: pointer;
&:hover {
background: rgba(#000, 0.85);
}
}
> p {
font-size: 11px;
color: rgba(#fff, 0.75);
}
}
> .volume {
position: absolute;
z-index: 5;
top: 0
bottom: 0;
right: 0;
width: 10px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> .bar {
width: 6px;
border-radius: 6px;
background: rgba(yellow, 0.65);
transition-property: height background-color;
transition-duration: 0.25s;
&.level0 { height: 0; background-color: rgba(yellow, 0.65); }
&.level1 { height: 10%; background-color: rgba(yellow, 0.65); }
&.level2 { height: 20%; background-color: rgba(yellow, 0.65); }
&.level3 { height: 30%; background-color: rgba(yellow, 0.65); }
&.level4 { height: 40%; background-color: rgba(orange, 0.65); }
&.level5 { height: 50%; background-color: rgba(orange, 0.65); }
&.level6 { height: 60%; background-color: rgba(red, 0.65); }
&.level7 { height: 70%; background-color: rgba(red, 0.65); }
&.level8 { height: 80%; background-color: rgba(#000, 0.65); }
&.level9 { height: 90%; background-color: rgba(#000, 0.65); }
&.level10 { height: 100%; background-color: rgba(#000, 0.65); }
}
}
> video {
height: 100%;
width: 100%;
object-fit: cover;
background-color: rgba(#041918, 0.65);
background-image: url('/resources/images/buddy.svg');
background-position: bottom;
background-size: auto 85%;
background-repeat: no-repeat;
&.mirror {
transform: scaleX(-1);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src:
local('Roboto Light'),
local('Roboto-Light'), url('/resources/fonts/Roboto-light-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src:
local('Roboto Light'),
local('Roboto-Light'),
url('/resources/fonts/Roboto-light.woff2') format('woff2'),
url('/resources/fonts/Roboto-light.ttf') format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src:
local('Roboto'),
local('Roboto-Regular'),
url('/resources/fonts/Roboto-regular-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src:
local('Roboto'),
local('Roboto-Regular'),
url('/resources/fonts/Roboto-regular.woff2') format('woff2'),
url('/resources/fonts/Roboto-regular.ttf') format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src:
local('Roboto Medium'),
local('Roboto-Medium'),
url('/resources/fonts/Roboto-medium-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src:
local('Roboto Medium'),
local('Roboto-Medium'),
url('/resources/fonts/Roboto-medium.woff2') format('woff2'),
url('/resources/fonts/Roboto-medium.ttf') format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
+67
View File
@@ -0,0 +1,67 @@
@import 'nib';
global-reset();
@import './mixins';
@import './fonts';
html {
font-family: 'Roboto';
background-image: url('/resources/images/body-bg-2.jpg');
background-attachment: fixed;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
min-height: 100vh;
width: 100%;
font-weight: 400;
+desktop() {
font-size: 16px;
}
+mobile() {
font-size: 12px;
}
}
body {
background: none;
}
* {
box-sizing: border-box;
outline: none;
}
#mediasoup-demo-app-container {
min-height: 100vh;
width: 100%;
// Components
@import './components/App';
@import './components/Room';
@import './components/LocalVideo';
@import './components/RemoteVideo';
@import './components/Video';
@import './components/Stats';
}
// Hack to detect in JS the current media query
#mediasoup-demo-app-media-query-detector {
position: relative;
z-index: -1000;
bottom: 0;
left: 0;
height: 1px;
width: 1px;
// In desktop let it "visible" so elem.offsetParent returns the parent element
+desktop() {}
// In mobile ensure it's not displayed so elem.offsetParent returns null
+mobile() {
display: none;
position: fixed; // Required for old IE
}
}
+33
View File
@@ -0,0 +1,33 @@
placeholder()
&::-webkit-input-placeholder
{block}
&:-moz-placeholder
{block}
&::-moz-placeholder
{block}
&:-ms-input-placeholder
{block}
text-fill-color()
-webkit-text-fill-color: arguments;
-moz-text-fill-color: arguments;
text-fill-color: arguments;
mobile()
@media (max-device-width: 720px)
{block}
desktop()
@media (min-device-width: 721px)
{block}
TransitionAppear($duration = 1s, $appearOpacity = 0, $activeOpacity = 1)
will-change: opacity;
&.transition-appear
opacity: $appearOpacity;
&.transition-appear.transition-appear-active
transition-property: opacity;
transition-duration: $duration;
opacity: $activeOpacity;