diff --git a/.gitignore b/.gitignore index 30d8e6c..01f5a19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ +/app/config.* +!/app/config.example.js /server/config.* !/server/config.example.js /server/public/ diff --git a/app/config.example.js b/app/config.example.js new file mode 100644 index 0000000..7734362 --- /dev/null +++ b/app/config.example.js @@ -0,0 +1,4 @@ +module.exports = +{ + chromeExtension : 'https://chrome.google.com/webstore/detail/fckajcjdaabdgnbdcmhhebdglogjfodi' +}; diff --git a/app/gulpfile.js b/app/gulpfile.js index 6f06c06..3eaa076 100644 --- a/app/gulpfile.js +++ b/app/gulpfile.js @@ -20,6 +20,7 @@ const gulpif = require('gulp-if'); const gutil = require('gulp-util'); const plumber = require('gulp-plumber'); const rename = require('gulp-rename'); +const change = require('gulp-change'); const header = require('gulp-header'); const touch = require('gulp-touch-cmd'); const browserify = require('browserify'); @@ -45,6 +46,7 @@ const BANNER_OPTIONS = currentYear : (new Date()).getFullYear() }; const OUTPUT_DIR = '../server/public'; +const appOptions = require('./config'); // Set Node 'development' environment (unless externally set). process.env.NODE_ENV = process.env.NODE_ENV || 'development'; @@ -123,6 +125,11 @@ function bundle(options) return rebundle(); } +function changeHTML(content) +{ + return content.replace(/chromeExtension/g, appOptions.chromeExtension); +} + gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); gulp.task('lint', () => @@ -163,6 +170,7 @@ gulp.task('css', () => gulp.task('html', () => { return gulp.src('index.html') + .pipe(change(changeHTML)) .pipe(gulp.dest(OUTPUT_DIR)); }); diff --git a/app/index.html b/app/index.html index 6f4ba8a..62d7bb6 100644 --- a/app/index.html +++ b/app/index.html @@ -8,6 +8,7 @@ + diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js index c9ebcf7..06654b0 100644 --- a/app/lib/RoomClient.js +++ b/app/lib/RoomClient.js @@ -1,6 +1,7 @@ import protooClient from 'protoo-client'; import * as mediasoupClient from 'mediasoup-client'; import Logger from './Logger'; +import ScreenShare from './ScreenShare'; import { getProtooUrl } from './urlFactory'; import * as cookiesManager from './cookiesManager'; import * as requestActions from './redux/requestActions'; @@ -79,12 +80,15 @@ export default class RoomClient // Local Webcam. Object with: // - {MediaDeviceInfo} [device] // - {String} [resolution] - 'qvga' / 'vga' / 'hd'. - this._webcam = - { + this._webcam = { device : null, resolution : 'hd' }; + this._screenSharing = ScreenShare.create(); + + this._screenSharingProducer = null; + this._join({ displayName, device }); } @@ -189,6 +193,79 @@ export default class RoomClient this._micProducer.resume(); } + installExtension() + { + logger.debug('installExtension()'); + + return new Promise((resolve, reject) => + { + window.addEventListener('message', _onExtensionMessage, false); + // eslint-disable-next-line no-undef + chrome.webstore.install(null, _successfulInstall, _failedInstall); + function _onExtensionMessage({ data }) + { + if (data.type === 'ScreenShareInjected') + { + logger.debug('installExtension() | installation succeeded'); + + return resolve(); + } + } + + function _failedInstall(reason) + { + window.removeEventListener('message', _onExtensionMessage); + + return reject( + new Error('Failed to install extension: %s', reason)); + } + + function _successfulInstall() + { + logger.debug('installExtension() | installation accepted'); + } + }) + .then(() => + { + // This should be handled better + this._dispatch(stateActions.setScreenCapabilities( + { + canShareScreen : this._room.canSend('video'), + needExtension : false + })); + }) + .catch((error) => + { + logger.error('installExtension() | failed: %o', error); + }); + } + + enableScreenSharing() + { + logger.debug('enableScreenSharing()'); + + this._dispatch( + stateActions.setScreenShareInProgress(true)); + + return Promise.resolve() + .then(() => + { + return this._setScreenShareProducer(); + }) + .then(() => + { + this._dispatch( + stateActions.setScreenShareInProgress(false)); + }) + .catch((error) => + { + logger.error('enableScreenSharing() | failed: %o', error); + + this._dispatch( + stateActions.setScreenShareInProgress(false)); + }); + } + enableWebcam() { logger.debug('enableWebcam()'); @@ -222,6 +299,30 @@ export default class RoomClient }); } + disableScreenSharing() + { + logger.debug('disableScreenSharing()'); + + this._dispatch( + stateActions.setScreenShareInProgress(true)); + + return Promise.resolve() + .then(() => + { + this._screenSharingProducer.close(); + + this._dispatch( + stateActions.setScreenShareInProgress(false)); + }) + .catch((error) => + { + logger.error('disableScreenSharing() | failed: %o', error); + + this._dispatch( + stateActions.setScreenShareInProgress(false)); + }); + } + disableWebcam() { logger.debug('disableWebcam()'); @@ -736,6 +837,12 @@ export default class RoomClient canSendMic : this._room.canSend('audio'), canSendWebcam : this._room.canSend('video') })); + this._dispatch(stateActions.setScreenCapabilities( + { + canShareScreen : this._room.canSend('video') && + this._screenSharing.isScreenShareAvailable(), + needExtension : this._screenSharing.needExtension() + })); }) .then(() => { @@ -906,6 +1013,117 @@ export default class RoomClient }); } + _setScreenShareProducer() + { + if (!this._room.canSend('video')) + { + return Promise.reject( + new Error('cannot send screen')); + } + + let producer; + + return Promise.resolve() + .then(() => + { + const available = this._screenSharing.isScreenShareAvailable() && + !this._screenSharing.needExtension(); + + if (!available) + throw new Error('screen sharing not available'); + + logger.debug('_setScreenShareProducer() | calling getUserMedia()'); + + return this._screenSharing.start({ + width : 1280, + height : 720, + frameRate : 3 + }); + }) + .then((stream) => + { + const track = stream.getVideoTracks()[0]; + + producer = this._room.createProducer( + track, { simulcast: false }, { source: 'screen' }); + + // No need to keep original track. + track.stop(); + + // Send it. + return producer.send(this._sendTransport); + }) + .then(() => + { + this._screenSharingProducer = producer; + + this._dispatch(stateActions.addProducer( + { + id : producer.id, + source : 'screen', + deviceLabel : 'screen', + type : 'screen', + locallyPaused : producer.locallyPaused, + remotelyPaused : producer.remotelyPaused, + track : producer.track, + codec : producer.rtpParameters.codecs[0].name + })); + + producer.on('close', (originator) => + { + logger.debug( + 'webcam Producer "close" event [originator:%s]', originator); + + this._screenSharingProducer = null; + this._dispatch(stateActions.removeProducer(producer.id)); + }); + + producer.on('pause', (originator) => + { + logger.debug( + 'webcam Producer "pause" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerPaused(producer.id, originator)); + }); + + producer.on('resume', (originator) => + { + logger.debug( + 'webcam Producer "resume" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerResumed(producer.id, originator)); + }); + + producer.on('handled', () => + { + logger.debug('webcam Producer "handled" event'); + }); + + producer.on('unhandled', () => + { + logger.debug('webcam Producer "unhandled" event'); + }); + }) + .then(() => + { + logger.debug('_setScreenShareProducer() succeeded'); + }) + .catch((error) => + { + logger.error('_setScreenShareProducer() failed:%o', error); + + this._dispatch(requestActions.notify( + { + text : `Screen share producer failed: ${error.name}:${error.message}` + })); + + if (producer) + producer.close(); + + throw error; + }); + } + _setWebcamProducer() { if (!this._room.canSend('video')) diff --git a/app/lib/ScreenShare.js b/app/lib/ScreenShare.js new file mode 100644 index 0000000..36d2760 --- /dev/null +++ b/app/lib/ScreenShare.js @@ -0,0 +1,212 @@ +import { getBrowserType } from './utils'; + +class ChromeScreenShare +{ + constructor() + { + this._stream = null; + } + + start(options = { }) + { + const state = this; + + return new Promise((resolve, reject) => + { + window.addEventListener('message', _onExtensionMessage, false); + window.postMessage({ type: 'getStreamId' }, '*'); + + function _onExtensionMessage({ data }) + { + if (data.type !== 'gotStreamId') + { + return; + } + + const constraints = state._toConstraints(options, data.streamId); + + navigator.mediaDevices.getUserMedia(constraints) + .then((stream) => + { + window.removeEventListener('message', _onExtensionMessage); + + state._stream = stream; + resolve(stream); + }) + .catch((err) => + { + window.removeEventListener('message', _onExtensionMessage); + + reject(err); + }); + } + }); + } + + stop() + { + if (this._stream instanceof MediaStream === false) + { + return; + } + + this._stream.getTracks().forEach((track) => track.stop()); + this._stream = null; + } + + isScreenShareAvailable() + { + if ('__multipartyMeetingScreenShareExtensionAvailable__' in window) + { + return true; + } + + return false; + } + + needExtension() + { + if ('__multipartyMeetingScreenShareExtensionAvailable__' in window) + { + return false; + } + + return true; + } + + _toConstraints(options, streamId) + { + const constraints = { + video : { + mandatory : { + chromeMediaSource : 'desktop', + chromeMediaSourceId : streamId + }, + optional : [ { + googTemporalLayeredScreencast : true + } ] + }, + audio : false + }; + + if (isFinite(options.width)) + { + constraints.video.mandatory.maxWidth = options.width; + constraints.video.mandatory.minWidth = options.width; + } + if (isFinite(options.height)) + { + constraints.video.mandatory.maxHeight = options.height; + constraints.video.mandatory.minHeight = options.height; + } + if (isFinite(options.frameRate)) + { + constraints.video.mandatory.maxFrameRate = options.frameRate; + constraints.video.mandatory.minFrameRate = options.frameRate; + } + + return constraints; + } +} + +class FirefoxScreenShare +{ + constructor() + { + this._stream = null; + } + + start(options = {}) + { + const constraints = this._toConstraints(options); + + return navigator.mediaDevices.getUserMedia(constraints) + .then((stream) => + { + this._stream = stream; + + return Promise.resolve(stream); + }); + } + + stop() + { + if (this._stream instanceof MediaStream === false) + { + return; + } + + this._stream.getTracks().forEach((track) => track.stop()); + this._stream = null; + } + + isScreenShareAvailable() + { + return true; + } + + needExtension() + { + return false; + } + + _toConstraints(options) + { + const constraints = { + video : { + mediaSource : 'window' + }, + audio : false + }; + + if ('mediaSource' in options) + { + constraints.video.mediaSource = options.mediaSource; + } + if (isFinite(options.width)) + { + constraints.video.width = { + min : options.width, + max : options.width + }; + } + if (isFinite(options.height)) + { + constraints.video.height = { + min : options.height, + max : options.height + }; + } + if (isFinite(options.frameRate)) + { + constraints.video.frameRate = { + min : options.frameRate, + max : options.frameRate + }; + } + + return constraints; + } +} + +export default class ScreenShare +{ + static create() + { + switch (getBrowserType()) + { + case 'firefox': + { + return new FirefoxScreenShare(); + } + case 'chrome': + { + return new ChromeScreenShare(); + } + default: + { + return null; + } + } + } +} diff --git a/app/lib/components/Peer.jsx b/app/lib/components/Peer.jsx index 61ed633..09c4fa2 100644 --- a/app/lib/components/Peer.jsx +++ b/app/lib/components/Peer.jsx @@ -8,7 +8,8 @@ const Peer = (props) => const { peer, micConsumer, - webcamConsumer + webcamConsumer, + screenConsumer } = props; const micEnabled = ( @@ -23,11 +24,22 @@ const Peer = (props) => !webcamConsumer.remotelyPaused ); + const screenVisible = ( + Boolean(screenConsumer) && + !screenConsumer.locallyPaused && + !screenConsumer.remotelyPaused + ); + let videoProfile; if (webcamConsumer) videoProfile = webcamConsumer.profile; + let screenProfile; + + if (screenConsumer) + screenProfile = screenConsumer.profile; + return (
@@ -52,10 +64,14 @@ const Peer = (props) => peer={peer} audioTrack={micConsumer ? micConsumer.track : null} videoTrack={webcamConsumer ? webcamConsumer.track : null} + screenTrack={screenConsumer ? screenConsumer.track : null} videoVisible={videoVisible} videoProfile={videoProfile} + screenVisible={screenVisible} + screenProfile={screenProfile} audioCodec={micConsumer ? micConsumer.codec : null} videoCodec={webcamConsumer ? webcamConsumer.codec : null} + screenCodec={screenConsumer ? screenConsumer.codec : null} />
); @@ -65,7 +81,8 @@ Peer.propTypes = { peer : appPropTypes.Peer.isRequired, micConsumer : appPropTypes.Consumer, - webcamConsumer : appPropTypes.Consumer + webcamConsumer : appPropTypes.Consumer, + screenConsumer : appPropTypes.Consumer }; const mapStateToProps = (state, { name }) => @@ -77,11 +94,14 @@ const mapStateToProps = (state, { name }) => consumersArray.find((consumer) => consumer.source === 'mic'); const webcamConsumer = consumersArray.find((consumer) => consumer.source === 'webcam'); + const screenConsumer = + consumersArray.find((consumer) => consumer.source === 'screen'); return { peer, micConsumer, - webcamConsumer + webcamConsumer, + screenConsumer }; }; diff --git a/app/lib/components/PeerView.jsx b/app/lib/components/PeerView.jsx index 840badd..e4f4572 100644 --- a/app/lib/components/PeerView.jsx +++ b/app/lib/components/PeerView.jsx @@ -14,9 +14,11 @@ export default class PeerView extends React.Component this.state = { - volume : 0, // Integer from 0 to 10., - videoWidth : null, - videoHeight : null + volume : 0, // Integer from 0 to 10., + videoWidth : null, + videoHeight : null, + screenWidth : null, + screenHeight : null }; // Latest received video track. @@ -27,6 +29,10 @@ export default class PeerView extends React.Component // @type {MediaStreamTrack} this._videoTrack = null; + // Latest received screen track. + // @type {MediaStreamTrack} + this._screenTrack = null; + // Hark instance. // @type {Object} this._hark = null; @@ -42,37 +48,60 @@ export default class PeerView extends React.Component peer, videoVisible, videoProfile, + screenVisible, + screenProfile, audioCodec, videoCodec, + screenCodec, onChangeDisplayName } = this.props; const { volume, videoWidth, - videoHeight + videoHeight, + screenWidth, + screenHeight } = this.state; return (
-
- {audioCodec ? -

{audioCodec}

- :null - } + {screenVisible ? +
+ {audioCodec ? +

{audioCodec}

+ :null + } - {videoCodec ? -

{videoCodec} {videoProfile}

- :null - } + {screenCodec ? +

{screenCodec} {screenProfile}

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

{videoWidth}x{videoHeight}

- :null - } -
+ {(screenVisible && screenWidth !== null) ? +

{screenWidth}x{screenHeight}

+ :null + } +
+ :
+ {audioCodec ? +

{audioCodec}

+ :null + } + + {videoCodec ? +

{videoCodec} {videoProfile}

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

{videoWidth}x{videoHeight}

+ :null + } +
+ }
@@ -111,19 +140,35 @@ export default class PeerView extends React.Component