diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8084d93 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +### 1.0 +* Fixed toolarea button based on feedback from users +* Added possibility to move video to separate window +* Added SIP gateway + +### RC1 1.0 +* First stable release? diff --git a/README.md b/README.md index 3e00c7b..50fdca4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,16 @@ A WebRTC meeting service using [mediasoup](https://mediasoup.org) as its backend. -Try it online at https://akademia.no. You can add /roomname to the URL for specifying a room. +Try it online at https://letsmeet.no. You can add /roomname to the URL for specifying a room. +## Features +* Audio/Video +* Chat +* Screen sharing +* File sharing +* Different video layouts + +There is also a SIP gateway that can be found [here](https://github.com/havfo/multiparty-meeting-sipgw). To test it, call: roomname@letsmeet.no. ## Installation @@ -22,15 +30,6 @@ $ cp server/config.example.js server/config.js * Copy `app/config.example.js` to `app/config.js` : -In addition, the server requires a screen to be installed for the server -to be able to seed shared torrent files. This is because the headless -Electron instance used by WebTorrent expects one. - -See [webtorrent-hybrid](https://github.com/webtorrent/webtorrent-hybrid) for -more information about this. - -* Copy `config.example.js` as `config.js` and customize it for your scenario: - ```bash $ cp app/config.example.js app/config.js ``` @@ -72,7 +71,7 @@ $ node server.js ## Deploy it in a server -* Stop your locally running server. Copy systemd-service file `multiparty-meeting.service` to `/etc/systemd/system/` and dobbel check location path settings: +* Stop your locally running server. Copy systemd-service file `multiparty-meeting.service` to `/etc/systemd/system/` and check location path settings: ```bash $ cp multiparty-meeting.service /etc/systemd/system/ $ edit /etc/systemd/system/multiparty-meeting.service @@ -97,7 +96,6 @@ $ systemctl enable multiparty-meeting * 40000-49999/udp/tcp (media ports - adjustable in `server/config.js`) * If you want your service running at standard ports 80/443 you should: - * Make a redirect from HTTP port 80 to HTTPS (with Apache/NGINX) * Configure a forwarding rule with iptables from port 443 to your configured service port (default 3443) diff --git a/app/.babelrc b/app/.babelrc new file mode 100644 index 0000000..b566ca5 --- /dev/null +++ b/app/.babelrc @@ -0,0 +1,25 @@ +{ + "plugins": + [ + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-runtime" + ], + "presets": + [ + [ + "@babel/preset-env", + { + "targets": { + "browsers": [ + "chrome >= 67", + "edge >= 17", + "firefox >= 60", + "safari >= 12" + ] + } + } + ], + "@babel/react" + ] +} diff --git a/app/.eslintrc.js b/app/.eslintrc.js index 6ec2ca4..ebced28 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -9,7 +9,8 @@ module.exports = plugins: [ 'import', - 'react' + 'react', + 'jsx-control-statements' ], extends: [ @@ -21,13 +22,13 @@ module.exports = react: { pragma: 'React', - version: '15' + version: '16' } }, - parser: 'babel-eslint', + parser: "babel-eslint", parserOptions: { - ecmaVersion: 9, + ecmaVersion: 2018, sourceType: 'module', ecmaFeatures: { @@ -177,6 +178,7 @@ module.exports = 'spaced-comment': [ 2, 'always' ], 'strict': 2, 'valid-typeof': 2, + 'eol-last': 0, 'yoda': 2, // eslint-plugin-import options. 'import/extensions': 2, @@ -197,7 +199,7 @@ module.exports = 'react/jsx-no-bind': 0, 'react/jsx-no-duplicate-props': 2, 'react/jsx-no-literals': 0, - 'react/jsx-no-undef': 2, + 'react/jsx-no-undef': 0, 'react/jsx-pascal-case': 2, 'react/jsx-sort-prop-types': 0, 'react/jsx-sort-props': 0, @@ -214,7 +216,7 @@ module.exports = 'react/no-string-refs': 0, 'react/no-unknown-property': 2, 'react/prefer-es6-class': 2, - 'react/prop-types': 2, + 'react/prop-types': [ 2, { skipUndeclared: true } ], 'react/react-in-jsx-scope': 2, 'react/self-closing-comp': 2, 'react/sort-comp': 0, diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/app/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/app/chooseRoom.html b/app/chooseRoom.html new file mode 100644 index 0000000..97d00de --- /dev/null +++ b/app/chooseRoom.html @@ -0,0 +1,74 @@ + + + + + Multiparty Meeting + + + + +
+
+ + + + + diff --git a/app/gulpfile.js b/app/gulpfile.js index 881d945..a04e04f 100644 --- a/app/gulpfile.js +++ b/app/gulpfile.js @@ -26,7 +26,7 @@ const touch = require('gulp-touch-cmd'); const browserify = require('browserify'); const watchify = require('watchify'); const envify = require('envify/custom'); -const uglify = require('gulp-uglify'); +const uglify = require('gulp-uglify-es').default; const source = require('vinyl-source-stream'); const buffer = require('vinyl-buffer'); const del = require('del'); @@ -77,10 +77,7 @@ function bundle(options) // required to be true only for watchify. fullPaths : watch }) - .transform('babelify', - { - presets : [ 'env', 'react-app' ] - }) + .transform('babelify') .transform(envify( { NODE_ENV : process.env.NODE_ENV, @@ -140,7 +137,7 @@ gulp.task('lint', () => .pipe(eslint.format()); }); -gulp.task('lint-fix', function() +gulp.task('lint-fix', function() { return gulp.src(LINTING_FILES) .pipe(plumber()) @@ -171,7 +168,7 @@ gulp.task('css', () => gulp.task('html', () => { - return gulp.src('index.html') + return gulp.src('*.html') .pipe(change(changeHTML)) .pipe(gulp.dest(OUTPUT_DIR)); }); @@ -244,7 +241,7 @@ gulp.task('browser', (done) => gulp.task('watch', (done) => { // Watch changes in HTML. - gulp.watch([ 'index.html' ], gulp.series( + gulp.watch([ '*.html' ], gulp.series( 'html' )); diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js index 094a82c..a902c73 100644 --- a/app/lib/RoomClient.js +++ b/app/lib/RoomClient.js @@ -1,9 +1,10 @@ -import protooClient from 'protoo-client'; +import io from 'socket.io-client'; import * as mediasoupClient from 'mediasoup-client'; import Logger from './Logger'; import hark from 'hark'; import ScreenShare from './ScreenShare'; -import { getProtooUrl } from './urlFactory'; +import Spotlights from './Spotlights'; +import { getSignalingUrl } from './urlFactory'; import * as cookiesManager from './cookiesManager'; import * as requestActions from './redux/requestActions'; import * as stateActions from './redux/stateActions'; @@ -15,11 +16,12 @@ import { const logger = new Logger('RoomClient'); -const ROOM_OPTIONS = +let ROOM_OPTIONS = { requestTimeout : requestTimeout, transportOptions : transportOptions, - turnServers : turnServers + turnServers : turnServers, + maxSpotlights : 4 }; const VIDEO_CONSTRAINS = @@ -37,8 +39,7 @@ export default class RoomClient 'constructor() [roomId:"%s", peerName:"%s", displayName:"%s", device:%s]', roomId, peerName, displayName, device.flag); - const protooUrl = getProtooUrl(peerName, roomId); - const protooTransport = new protooClient.WebSocketTransport(protooUrl); + const signalingUrl = getSignalingUrl(peerName, roomId); // window element to external login site this._loginWindow; @@ -64,13 +65,25 @@ export default class RoomClient // My peer name. this._peerName = peerName; - // protoo-client Peer instance. - this._protoo = new protooClient.Peer(protooTransport); + // Alert sound + this._soundAlert = new Audio('/resources/sounds/notify.mp3'); + + // Socket.io peer connection + this._signalingSocket = io(signalingUrl); + + if (this._device.flag === 'firefox') + ROOM_OPTIONS = Object.assign({ iceTransportPolicy: 'relay' }, ROOM_OPTIONS); // mediasoup-client Room instance. this._room = new mediasoupClient.Room(ROOM_OPTIONS); this._room.roomId = roomId; + // Max spotlights + this._maxSpotlights = ROOM_OPTIONS.maxSpotlights; + + // Manager of spotlight + this._spotlights = new Spotlights(this._maxSpotlights, this._room); + // Transport for sending. this._sendTransport = null; @@ -105,6 +118,8 @@ export default class RoomClient this._screenSharingProducer = null; + this._startKeyListener(); + this._join({ displayName, device }); } @@ -120,13 +135,62 @@ export default class RoomClient // Leave the mediasoup Room. this._room.leave(); - // Close protoo Peer (wait a bit so mediasoup-client can send + // Close signaling Peer (wait a bit so mediasoup-client can send // the 'leaveRoom' notification). - setTimeout(() => this._protoo.close(), 250); + setTimeout(() => this._signalingSocket.close(), 250); this._dispatch(stateActions.setRoomState('closed')); } + _startKeyListener() + { + // Add keypress event listner on document + document.addEventListener('keypress', (event) => + { + const key = String.fromCharCode(event.which); + + const source = event.target; + + const exclude = [ 'input', 'textarea' ]; + + if (exclude.indexOf(source.tagName.toLowerCase()) === -1) + { + logger.debug('keyPress() [key:"%s"]', key); + + switch (key) + { + case 'a': // Activate advanced mode + { + this._dispatch(stateActions.toggleAdvancedMode()); + this.notify('Toggled advanced mode.'); + break; + } + + case '1': // Set democratic view + { + this._dispatch(stateActions.setDisplayMode('democratic')); + this.notify('Changed layout to democratic view.'); + break; + } + + case '2': // Set filmstrip view + { + this._dispatch(stateActions.setDisplayMode('filmstrip')); + this.notify('Changed layout to filmstrip view.'); + break; + } + + case 'm': // Toggle microphone + { + this.toggleMic(); + this.notify('Muted/unmuted your microphone.'); + break; + } + } + } + }); + } + login() { const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`; @@ -144,114 +208,204 @@ export default class RoomClient this._loginWindow.close(); } - changeDisplayName(displayName) + _soundNotification() + { + const alertPromise = this._soundAlert.play(); + + if (alertPromise !== undefined) + { + alertPromise + .then() + .catch((error) => + { + logger.error('_soundAlert.play() | failed: %o', error); + }); + } + } + + notify(text) + { + this._dispatch(requestActions.notify({ text: text })); + } + + timeoutCallback(callback) + { + let called = false; + + const interval = setTimeout( + () => + { + if (called) + return; + called = true; + callback(new Error('Request timeout.')); + }, + ROOM_OPTIONS.requestTimeout + ); + + return (...args) => + { + if (called) + return; + called = true; + clearTimeout(interval); + + callback(...args); + }; + } + + sendRequest(method, data) + { + return new Promise((resolve, reject) => + { + if (!this._signalingSocket) + { + reject('No socket connection.'); + } + else + { + this._signalingSocket.emit(method, data, this.timeoutCallback((err, response) => + { + if (err) + { + reject(err); + } + else + { + resolve(response); + } + })); + } + }); + } + + async changeDisplayName(displayName) { logger.debug('changeDisplayName() [displayName:"%s"]', displayName); // Store in cookie. cookiesManager.setUser({ displayName }); - return this._protoo.send('change-display-name', { displayName }) - .then(() => - { - this._dispatch( - stateActions.setDisplayName(displayName)); + try + { + await this.sendRequest('change-display-name', { displayName }); - this._dispatch(requestActions.notify( - { - text : 'Display name changed' - })); - }) - .catch((error) => - { - logger.error('changeDisplayName() | failed: %o', error); + this._dispatch(stateActions.setDisplayName(displayName)); - this._dispatch(requestActions.notify( - { - type : 'error', - text : `Could not change display name: ${error}` - })); + this.notify(`Your display name changed to ${displayName}.`); + } + catch (error) + { + logger.error('changeDisplayName() | failed: %o', error); - // We need to refresh the component for it to render the previous - // displayName again. - this._dispatch(stateActions.setDisplayName()); - }); + this.notify('An error occured while changing your display name.'); + + // We need to refresh the component for it to render the previous + // displayName again. + this._dispatch(stateActions.setDisplayName()); + } } - changeProfilePicture(picture) + async changeProfilePicture(picture) { logger.debug('changeProfilePicture() [picture: "%s"]', picture); - this._protoo.send('change-profile-picture', { picture }).catch((error) => + try + { + await this.sendRequest('change-profile-picture', { picture }); + } + catch (error) { logger.error('shareProfilePicure() | failed: %o', error); - }); + } } - sendChatMessage(chatMessage) + async sendChatMessage(chatMessage) { logger.debug('sendChatMessage() [chatMessage:"%s"]', chatMessage); - return this._protoo.send('chat-message', { chatMessage }) - .catch((error) => - { - logger.error('sendChatMessage() | failed: %o', error); + try + { + await this.sendRequest('chat-message', { chatMessage }); + } + catch (error) + { + logger.error('sendChatMessage() | failed: %o', error); - this._dispatch(requestActions.notify( - { - type : 'error', - text : `Could not send chat: ${error}` - })); - }); + this.notify('An error occured while sending chat message.'); + } } - sendFile(file) + async sendFile(file) { logger.debug('sendFile() [file: %o]', file); - return this._protoo.send('send-file', { file }) - .catch((error) => - { - logger.error('sendFile() | failed: %o', error); + try + { + await this.sendRequest('send-file', { file }); + } + catch (error) + { + logger.error('sendFile() | failed: %o', error); - this._dispatch(requestActions.notify({ - typ : 'error', - text : 'An error occurred while sharing a file' - })); - }); + this.notify('An error occurred while sharing file.'); + } } - getChatHistory() + async getServerHistory() { - logger.debug('getChatHistory()'); + logger.debug('getServerHistory()'); - return this._protoo.send('chat-history', {}) - .catch((error) => + try + { + const { + chatHistory, + fileHistory, + lastN + } = await this.sendRequest('server-history'); + + if (chatHistory.length > 0) { - logger.error('getChatHistory() | failed: %o', error); + logger.debug('Got chat history'); + this._dispatch( + stateActions.addChatHistory(chatHistory)); + } - this._dispatch(requestActions.notify( - { - type : 'error', - text : `Could not get chat history: ${error}` - })); - }); + if (fileHistory.length > 0) + { + logger.debug('Got files history'); + + this._dispatch(stateActions.addFileHistory(fileHistory)); + } + + if (lastN.length > 0) + { + logger.debug('Got lastN'); + + // Remove our self from list + const index = lastN.indexOf(this._peerName); + + lastN.splice(index, 1); + + this._spotlights.addSpeakerList(lastN); + } + } + catch (error) + { + logger.error('getServerHistory() | failed: %o', error); + + this.notify('An error occured while getting server history.'); + } } - getFileHistory() + toggleMic() { - logger.debug('getFileHistory()'); + logger.debug('toggleMic()'); - return this._protoo.send('file-history', {}) - .catch((error) => - { - logger.error('getFileHistory() | failed: %o', error); - - this._dispatch(requestActions.notify({ - type : 'error', - text : 'Could not get file history' - })); - }); + if (this._micProducer.locallyPaused) + this.unmuteMic(); + else + this.muteMic(); } muteMic() @@ -268,6 +422,43 @@ export default class RoomClient this._micProducer.resume(); } + // Updated consumers based on spotlights + async updateSpotlights(spotlights) + { + logger.debug('updateSpotlights()'); + + try + { + for (const peer of this._room.peers) + { + if (spotlights.indexOf(peer.name) > -1) // Resume video for speaker + { + for (const consumer of peer.consumers) + { + if (consumer.kind !== 'video' || !consumer.supported) + continue; + + await consumer.resume(); + } + } + else // Pause video for everybody else + { + for (const consumer of peer.consumers) + { + if (consumer.kind !== 'video') + continue; + + await consumer.pause('not-speaker'); + } + } + } + } + catch (error) + { + logger.error('updateSpotlights() failed: %o', error); + } + } + installExtension() { logger.debug('installExtension()'); @@ -315,253 +506,221 @@ export default class RoomClient }); } - enableScreenSharing() + async enableScreenSharing() { logger.debug('enableScreenSharing()'); - this._dispatch( - stateActions.setScreenShareInProgress(true)); + 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); + try + { + await this._setScreenShareProducer(); + } + catch (error) + { + logger.error('enableScreenSharing() | failed: %o', error); + } - this._dispatch( - stateActions.setScreenShareInProgress(false)); - }); + this._dispatch(stateActions.setScreenShareInProgress(false)); } - enableWebcam() + async enableWebcam() { logger.debug('enableWebcam()'); // Store in cookie. cookiesManager.setDevices({ webcamEnabled: true }); - this._dispatch( - stateActions.setWebcamInProgress(true)); + this._dispatch(stateActions.setWebcamInProgress(true)); - return Promise.resolve() - .then(() => - { - return this._updateWebcams(); - }) - .then(() => - { - return this._setWebcamProducer(); - }) - .then(() => - { - this._dispatch( - stateActions.setWebcamInProgress(false)); - }) - .catch((error) => - { - logger.error('enableWebcam() | failed: %o', error); + try + { + await this._setWebcamProducer(); + } + catch (error) + { + logger.error('enableWebcam() | failed: %o', error); + } - this._dispatch( - stateActions.setWebcamInProgress(false)); - }); + this._dispatch(stateActions.setWebcamInProgress(false)); } - disableScreenSharing() + async disableScreenSharing() { logger.debug('disableScreenSharing()'); - this._dispatch( - stateActions.setScreenShareInProgress(true)); + this._dispatch(stateActions.setScreenShareInProgress(true)); - return Promise.resolve() - .then(() => - { - this._screenSharingProducer.close(); + try + { + await this._screenSharingProducer.close(); + } + catch (error) + { + logger.error('disableScreenSharing() | failed: %o', error); + } - this._dispatch( - stateActions.setScreenShareInProgress(false)); - }) - .catch((error) => - { - logger.error('disableScreenSharing() | failed: %o', error); - - this._dispatch( - stateActions.setScreenShareInProgress(false)); - }); + this._dispatch(stateActions.setScreenShareInProgress(false)); } - disableWebcam() + async disableWebcam() { logger.debug('disableWebcam()'); // Store in cookie. cookiesManager.setDevices({ webcamEnabled: false }); - this._dispatch( - stateActions.setWebcamInProgress(true)); + this._dispatch(stateActions.setWebcamInProgress(true)); - return Promise.resolve() - .then(() => - { - this._webcamProducer.close(); + try + { + this._webcamProducer.close(); + } + catch (error) + { + logger.error('disableWebcam() | failed: %o', error); + } - this._dispatch( - stateActions.setWebcamInProgress(false)); - }) - .catch((error) => - { - logger.error('disableWebcam() | failed: %o', error); - - this._dispatch( - stateActions.setWebcamInProgress(false)); - }); + this._dispatch(stateActions.setWebcamInProgress(false)); } - changeAudioDevice(deviceId) + async changeAudioDevice(deviceId) { logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); this._dispatch( stateActions.setAudioInProgress(true)); - return Promise.resolve() - .then(() => - { - this._audioDevice.device = this._audioDevices.get(deviceId); + try + { + this._audioDevice.device = this._audioDevices.get(deviceId); - logger.debug( - 'changeAudioDevice() | new selected webcam [device:%o]', - this._audioDevice.device); - }) - .then(() => - { - const { device } = this._audioDevice; + logger.debug( + 'changeAudioDevice() | new selected webcam [device:%o]', + this._audioDevice.device); - if (!device) - throw new Error('no audio devices'); + const { device } = this._audioDevice; - logger.debug('changeAudioDevice() | calling getUserMedia()'); + if (!device) + throw new Error('no audio devices'); - return navigator.mediaDevices.getUserMedia( + logger.debug('changeAudioDevice() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + audio : { - audio : - { - deviceId : { exact: device.deviceId } - } - }); - }) - .then((stream) => - { - const track = stream.getAudioTracks()[0]; + deviceId : { exact: device.deviceId } + } + }); - return this._micProducer.replaceTrack(track) - .then((newTrack) => - { - track.stop(); + const track = stream.getAudioTracks()[0]; - return newTrack; - }); - }) - .then((newTrack) => - { - this._dispatch( - stateActions.setProducerTrack(this._micProducer.id, newTrack)); + const newTrack = await this._micProducer.replaceTrack(track); - return this._updateAudioDevices(); - }) - .then(() => - { - this._dispatch( - stateActions.setAudioInProgress(false)); - }) - .catch((error) => - { - logger.error('changeAudioDevice() failed: %o', error); + const harkStream = new MediaStream; - this._dispatch( - stateActions.setAudioInProgress(false)); + harkStream.addTrack(newTrack); + if (!harkStream.getAudioTracks()[0]) + throw new Error('changeAudioDevice(): given stream has no audio track'); + if (this._micProducer.hark != null) this._micProducer.hark.stop(); + this._micProducer.hark = hark(harkStream, { play: false }); + + // eslint-disable-next-line no-unused-vars + this._micProducer.hark.on('volume_change', (dBs, threshold) => + { + // The exact formula to convert from dBs (-100..0) to linear (0..1) is: + // Math.pow(10, dBs / 20) + // However it does not produce a visually useful output, so let exagerate + // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to + // minimize component renderings. + let volume = Math.round(Math.pow(10, dBs / 85) * 10); + + if (volume === 1) + volume = 0; + + if (volume !== this._micProducer.volume) + { + this._micProducer.volume = volume; + this._dispatch(stateActions.setProducerVolume(this._micProducer.id, volume)); + } }); + + track.stop(); + + this._dispatch( + stateActions.setProducerTrack(this._micProducer.id, newTrack)); + + cookiesManager.setAudioDevice({ audioDeviceId: deviceId }); + + await this._updateAudioDevices(); + } + catch (error) + { + logger.error('changeAudioDevice() failed: %o', error); + } + + this._dispatch( + stateActions.setAudioInProgress(false)); } - changeWebcam(deviceId) + async changeWebcam(deviceId) { logger.debug('changeWebcam() [deviceId: %s]', deviceId); this._dispatch( stateActions.setWebcamInProgress(true)); - return Promise.resolve() - .then(() => - { - this._webcam.device = this._webcams.get(deviceId); + try + { + this._webcam.device = this._webcams.get(deviceId); - logger.debug( - 'changeWebcam() | new selected webcam [device:%o]', - this._webcam.device); + logger.debug( + 'changeWebcam() | new selected webcam [device:%o]', + this._webcam.device); - // Reset video resolution to HD. - this._webcam.resolution = 'hd'; - }) - .then(() => - { - const { device } = this._webcam; + // Reset video resolution to HD. + this._webcam.resolution = 'hd'; - if (!device) - throw new Error('no webcam devices'); + const { device } = this._webcam; - logger.debug('changeWebcam() | calling getUserMedia()'); + if (!device) + throw new Error('no webcam devices'); - return navigator.mediaDevices.getUserMedia( + logger.debug('changeWebcam() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : { - video : - { - deviceId : { exact: device.deviceId }, - ...VIDEO_CONSTRAINS - } - }); - }) - .then((stream) => - { - const track = stream.getVideoTracks()[0]; + deviceId : { exact: device.deviceId }, + ...VIDEO_CONSTRAINS + } + }); - return this._webcamProducer.replaceTrack(track) - .then((newTrack) => - { - track.stop(); + const track = stream.getVideoTracks()[0]; - return newTrack; - }); - }) - .then((newTrack) => - { - this._dispatch( - stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + const newTrack = await this._webcamProducer.replaceTrack(track); - return this._updateWebcams(); - }) - .then(() => - { - this._dispatch( - stateActions.setWebcamInProgress(false)); - }) - .catch((error) => - { - logger.error('changeWebcam() failed: %o', error); + track.stop(); - this._dispatch( - stateActions.setWebcamInProgress(false)); - }); + this._dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + + cookiesManager.setVideoDevice({ videoDeviceId: deviceId }); + + await this._updateWebcams(); + } + catch (error) + { + logger.error('changeWebcam() failed: %o', error); + } + + this._dispatch( + stateActions.setWebcamInProgress(false)); } - changeWebcamResolution() + async changeWebcamResolution() { logger.debug('changeWebcamResolution()'); @@ -571,649 +730,553 @@ export default class RoomClient this._dispatch( stateActions.setWebcamInProgress(true)); - return Promise.resolve() - .then(() => - { - oldResolution = this._webcam.resolution; + try + { + oldResolution = this._webcam.resolution; - switch (oldResolution) + switch (oldResolution) + { + case 'qvga': + newResolution = 'vga'; + break; + case 'vga': + newResolution = 'hd'; + break; + case 'hd': + newResolution = 'qvga'; + break; + } + + this._webcam.resolution = newResolution; + + const { device } = this._webcam; + + logger.debug('changeWebcamResolution() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( { - case 'qvga': - newResolution = 'vga'; - break; - case 'vga': - newResolution = 'hd'; - break; - case 'hd': - newResolution = 'qvga'; - break; - } - - this._webcam.resolution = newResolution; - }) - .then(() => - { - const { device } = this._webcam; - - logger.debug('changeWebcamResolution() | calling getUserMedia()'); - - return navigator.mediaDevices.getUserMedia( + video : { - video : - { - deviceId : { exact: device.deviceId }, - ...VIDEO_CONSTRAINS - } - }); - }) - .then((stream) => - { - const track = stream.getVideoTracks()[0]; + deviceId : { exact: device.deviceId }, + ...VIDEO_CONSTRAINS + } + }); - return this._webcamProducer.replaceTrack(track) - .then((newTrack) => - { - track.stop(); + const track = stream.getVideoTracks()[0]; - return newTrack; - }); - }) - .then((newTrack) => - { - this._dispatch( - stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + const newTrack = await this._webcamProducer.replaceTrack(track); - this._dispatch( - stateActions.setWebcamInProgress(false)); - }) - .catch((error) => - { - logger.error('changeWebcamResolution() failed: %o', error); + track.stop(); - this._dispatch( - stateActions.setWebcamInProgress(false)); + this._dispatch( + stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + } + catch (error) + { + logger.error('changeWebcamResolution() failed: %o', error); - this._webcam.resolution = oldResolution; - }); + this._webcam.resolution = oldResolution; + } + + this._dispatch( + stateActions.setWebcamInProgress(false)); } - mutePeerAudio(peerName) + setSelectedPeer(peerName) + { + logger.debug('setSelectedPeer() [peerName:"%s"]', peerName); + + this._spotlights.setPeerSpotlight(peerName); + + this._dispatch( + stateActions.setSelectedPeer(peerName)); + } + + async mutePeerAudio(peerName) { logger.debug('mutePeerAudio() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerAudioInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'mic') - continue; + if (consumer.appData.source !== 'mic') + continue; - consumer.pause('mute-audio'); - } + await consumer.pause('mute-audio'); } } + } + } + catch (error) + { + logger.error('mutePeerAudio() failed: %o', error); + } - this._dispatch( - stateActions.setPeerAudioInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('mutePeerAudio() failed: %o', error); - - this._dispatch( - stateActions.setPeerAudioInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerAudioInProgress(peerName, false)); } - unmutePeerAudio(peerName) + async unmutePeerAudio(peerName) { logger.debug('unmutePeerAudio() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerAudioInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'mic' || !consumer.supported) - continue; + if (consumer.appData.source !== 'mic' || !consumer.supported) + continue; - consumer.resume(); - } + await consumer.resume(); } } + } + } + catch (error) + { + logger.error('unmutePeerAudio() failed: %o', error); + } - this._dispatch( - stateActions.setPeerAudioInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('unmutePeerAudio() failed: %o', error); - - this._dispatch( - stateActions.setPeerAudioInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerAudioInProgress(peerName, false)); } - pausePeerVideo(peerName) + async pausePeerVideo(peerName) { logger.debug('pausePeerVideo() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerVideoInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'webcam') - continue; + if (consumer.appData.source !== 'webcam') + continue; - consumer.pause('pause-video'); - } + await consumer.pause('pause-video'); } } + } + } + catch (error) + { + logger.error('pausePeerVideo() failed: %o', error); + } - this._dispatch( - stateActions.setPeerVideoInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('pausePeerVideo() failed: %o', error); - - this._dispatch( - stateActions.setPeerVideoInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerVideoInProgress(peerName, false)); } - resumePeerVideo(peerName) + async resumePeerVideo(peerName) { logger.debug('resumePeerVideo() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerVideoInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'webcam' || !consumer.supported) - continue; + if (consumer.appData.source !== 'webcam' || !consumer.supported) + continue; - consumer.resume(); - } + await consumer.resume(); } } + } + } + catch (error) + { + logger.error('resumePeerVideo() failed: %o', error); + } - this._dispatch( - stateActions.setPeerVideoInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('resumePeerVideo() failed: %o', error); - - this._dispatch( - stateActions.setPeerVideoInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerVideoInProgress(peerName, false)); } - pausePeerScreen(peerName) + async pausePeerScreen(peerName) { logger.debug('pausePeerScreen() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerScreenInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'screen') - continue; + if (consumer.appData.source !== 'screen') + continue; - consumer.pause('pause-screen'); - } + await consumer.pause('pause-screen'); } } + } + } + catch (error) + { + logger.error('pausePeerScreen() failed: %o', error); + } - this._dispatch( - stateActions.setPeerScreenInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('pausePeerScreen() failed: %o', error); - - this._dispatch( - stateActions.setPeerScreenInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerScreenInProgress(peerName, false)); } - resumePeerScreen(peerName) + async resumePeerScreen(peerName) { logger.debug('resumePeerScreen() [peerName:"%s"]', peerName); this._dispatch( stateActions.setPeerScreenInProgress(peerName, true)); - return Promise.resolve() - .then(() => + try + { + for (const peer of this._room.peers) { - for (const peer of this._room.peers) + if (peer.name === peerName) { - if (peer.name === peerName) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.appData.source !== 'screen' || !consumer.supported) - continue; + if (consumer.appData.source !== 'screen' || !consumer.supported) + continue; - consumer.resume(); - } + await consumer.resume(); } } + } + } + catch (error) + { + logger.error('resumePeerScreen() failed: %o', error); + } - this._dispatch( - stateActions.setPeerScreenInProgress(peerName, false)); - }) - .catch((error) => - { - logger.error('resumePeerScreen() failed: %o', error); - - this._dispatch( - stateActions.setPeerScreenInProgress(peerName, false)); - }); + this._dispatch( + stateActions.setPeerScreenInProgress(peerName, false)); } - enableAudioOnly() + async enableAudioOnly() { logger.debug('enableAudioOnly()'); this._dispatch( stateActions.setAudioOnlyInProgress(true)); - return Promise.resolve() - .then(() => - { - if (this._webcamProducer) - this._webcamProducer.close(); + try + { + if (this._webcamProducer) + await this._webcamProducer.close(); - for (const peer of this._room.peers) + for (const peer of this._room.peers) + { + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.kind !== 'video') - continue; + if (consumer.kind !== 'video') + continue; - consumer.pause('audio-only-mode'); - } + await consumer.pause('audio-only-mode'); } + } - this._dispatch( - stateActions.setAudioOnlyState(true)); + this._dispatch( + stateActions.setAudioOnlyState(true)); + } + catch (error) + { + logger.error('enableAudioOnly() failed: %o', error); + } - this._dispatch( - stateActions.setAudioOnlyInProgress(false)); - }) - .catch((error) => - { - logger.error('enableAudioOnly() failed: %o', error); - - this._dispatch( - stateActions.setAudioOnlyInProgress(false)); - }); + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); } - disableAudioOnly() + async disableAudioOnly() { logger.debug('disableAudioOnly()'); this._dispatch( stateActions.setAudioOnlyInProgress(true)); - return Promise.resolve() - .then(() => + try + { + if (!this._webcamProducer && this._room.canSend('video')) + await this.enableWebcam(); + + for (const peer of this._room.peers) { - if (!this._webcamProducer && this._room.canSend('video')) - return this.enableWebcam(); - }) - .then(() => - { - for (const peer of this._room.peers) + for (const consumer of peer.consumers) { - for (const consumer of peer.consumers) - { - if (consumer.kind !== 'video' || !consumer.supported) - continue; + if (consumer.kind !== 'video' || !consumer.supported) + continue; - consumer.resume(); - } + await consumer.resume(); } + } - this._dispatch( - stateActions.setAudioOnlyState(false)); + this._dispatch( + stateActions.setAudioOnlyState(false)); + } + catch (error) + { + logger.error('disableAudioOnly() failed: %o', error); + } - this._dispatch( - stateActions.setAudioOnlyInProgress(false)); - }) - .catch((error) => - { - logger.error('disableAudioOnly() failed: %o', error); - - this._dispatch( - stateActions.setAudioOnlyInProgress(false)); - }); + this._dispatch( + stateActions.setAudioOnlyInProgress(false)); } - sendRaiseHandState(state) + async sendRaiseHandState(state) { logger.debug('sendRaiseHandState: ', state); this._dispatch( stateActions.setMyRaiseHandStateInProgress(true)); - return this._protoo.send('raisehand-message', { raiseHandState: state }) - .then(() => - { - this._dispatch( - stateActions.setMyRaiseHandState(state)); - this._dispatch( - stateActions.setMyRaiseHandStateInProgress(false)); - }) - .catch((error) => - { - logger.error('sendRaiseHandState() | failed: %o', error); + try + { + await this.sendRequest('raisehand-message', { raiseHandState: state }); - this._dispatch(requestActions.notify( - { - type : 'error', - text : `Could not change raise hand state: ${error}` - })); + this._dispatch( + stateActions.setMyRaiseHandState(state)); + } + catch (error) + { + logger.error('sendRaiseHandState() | failed: %o', error); - // We need to refresh the component for it to render changed state - this._dispatch(stateActions.setMyRaiseHandState(!state)); - this._dispatch( - stateActions.setMyRaiseHandStateInProgress(false)); - }); + this.notify(`An error occured while ${state ? 'raising' : 'lowering'} hand.`); + + // We need to refresh the component for it to render changed state + this._dispatch(stateActions.setMyRaiseHandState(!state)); + } + + this._dispatch( + stateActions.setMyRaiseHandStateInProgress(false)); } - restartIce() + async restartIce() { logger.debug('restartIce()'); this._dispatch( stateActions.setRestartIceInProgress(true)); - return Promise.resolve() - .then(() => - { - this._room.restartIce(); + try + { + await this._room.restartIce(); + } + catch (error) + { + logger.error('restartIce() failed: %o', error); + } - // Make it artificially longer. - setTimeout(() => - { - this._dispatch( - stateActions.setRestartIceInProgress(false)); - }, 500); - }) - .catch((error) => - { - logger.error('restartIce() failed: %o', error); - - this._dispatch( - stateActions.setRestartIceInProgress(false)); - }); + // Make it artificially longer. + setTimeout(() => + { + this._dispatch( + stateActions.setRestartIceInProgress(false)); + }, 500); } _join({ displayName, device }) { this._dispatch(stateActions.setRoomState('connecting')); - this._protoo.on('open', () => + this._signalingSocket.on('connect', () => { - logger.debug('protoo Peer "open" event'); + logger.debug('signaling Peer "connect" event'); this._joinRoom({ displayName, device }); }); - this._protoo.on('disconnected', () => + this._signalingSocket.on('disconnect', () => { - logger.warn('protoo Peer "disconnected" event'); + logger.warn('signaling Peer "disconnect" event'); - this._dispatch(requestActions.notify( - { - type : 'error', - text : 'WebSocket disconnected' - })); + this.notify('You are disconnected.'); // Leave Room. - try { this._room.remoteClose({ cause: 'protoo disconnected' }); } + try { this._room.remoteClose({ cause: 'signaling disconnected' }); } catch (error) {} this._dispatch(stateActions.setRoomState('connecting')); }); - this._protoo.on('close', () => + this._signalingSocket.on('close', () => { if (this._closed) return; - logger.warn('protoo Peer "close" event'); + logger.warn('signaling Peer "close" event'); this.close(); }); - this._protoo.on('request', (request, accept, reject) => + this._signalingSocket.on('mediasoup-notification', (data) => { - logger.debug( - '_handleProtooRequest() [method:%s, data:%o]', - request.method, request.data); + const notification = data; - switch (request.method) + this._room.receiveNotification(notification); + }); + + this._signalingSocket.on('active-speaker', (data) => + { + const { peerName } = data; + + this._dispatch( + stateActions.setRoomActiveSpeaker(peerName)); + + if (peerName && peerName !== this._peerName) + this._spotlights.handleActiveSpeaker(peerName); + }); + + this._signalingSocket.on('display-name-changed', (data) => + { + // eslint-disable-next-line no-shadow + const { peerName, displayName } = data; + + // NOTE: Hack, we shouldn't do this, but this is just a demo. + const peer = this._room.getPeerByName(peerName); + + if (!peer) { - case 'mediasoup-notification': - { - accept(); + logger.error('peer not found'); - const notification = request.data; + return; + } - this._room.receiveNotification(notification); + const oldDisplayName = peer.appData.displayName; - break; - } + peer.appData.displayName = displayName; - case 'active-speaker': - { - accept(); + this._dispatch( + stateActions.setPeerDisplayName(displayName, peerName)); - const { peerName } = request.data; + this.notify(`${oldDisplayName} changed their display name to ${displayName}.`); + }); - this._dispatch( - stateActions.setRoomActiveSpeaker(peerName)); + this._signalingSocket.on('profile-picture-changed', (data) => + { + const { peerName, picture } = data; - break; - } + this._dispatch(stateActions.setPeerPicture(peerName, picture)); + }); - case 'display-name-changed': - { - accept(); + // This means: server wants to change MY user information + this._signalingSocket.on('auth', (data) => + { + logger.debug('got auth event from server', data); - // eslint-disable-next-line no-shadow - const { peerName, displayName, oldDisplayName } = request.data; + this.changeDisplayName(data.name); - // NOTE: Hack, we shouldn't do this, but this is just a demo. - const peer = this._room.getPeerByName(peerName); + this.changeProfilePicture(data.picture); + this._dispatch(stateActions.setPicture(data.picture)); + this._dispatch(stateActions.loggedIn()); - if (!peer) - { - logger.error('peer not found'); + this.notify('You are logged in.'); - break; - } + this.closeLoginWindow(); + }); - peer.appData.displayName = displayName; + this._signalingSocket.on('raisehand-message', (data) => + { + const { peerName, raiseHandState } = data; - this._dispatch( - stateActions.setPeerDisplayName(displayName, peerName)); + logger.debug('Got raiseHandState from "%s"', peerName); - this._dispatch(requestActions.notify( - { - text : `${oldDisplayName} is now ${displayName}` - })); + // NOTE: Hack, we shouldn't do this, but this is just a demo. + const peer = this._room.getPeerByName(peerName); - break; - } + if (!peer) + { + logger.error('peer not found'); - case 'profile-picture-changed': - { - accept(); + return; + } - const { peerName, picture } = request.data; + this.notify(`${peer.appData.displayName} ${raiseHandState ? 'raised' : 'lowered'} their hand.`); - this._dispatch(stateActions.setPeerPicture(peerName, picture)); + this._dispatch( + stateActions.setPeerRaiseHandState(peerName, raiseHandState)); + }); - break; - } + this._signalingSocket.on('chat-message-receive', (data) => + { + const { peerName, chatMessage } = data; - // This means: server wants to change MY user information - case 'auth': - { - logger.debug('got auth event from server', request.data); - accept(); + logger.debug('Got chat from "%s"', peerName); - this.changeDisplayName(request.data.name); + this._dispatch( + stateActions.addResponseMessage({ ...chatMessage, peerName })); - this.changeProfilePicture(request.data.picture); - this._dispatch(stateActions.setPicture(request.data.picture)); - this._dispatch(stateActions.loggedIn()); + if (!this._getState().toolarea.toolAreaOpen || + (this._getState().toolarea.toolAreaOpen && + this._getState().toolarea.currentToolTab !== 'chat')) // Make sound + { + this._soundNotification(); + } + }); - this._dispatch(requestActions.notify( - { - text : `Authenticated successfully: ${request.data}` - } - )); + this._signalingSocket.on('file-receive', (data) => + { + const { peerName, file } = data; - this.closeLoginWindow(); + // NOTE: Hack, we shouldn't do this, but this is just a demo. + const peer = this._room.getPeerByName(peerName); - break; - } + if (!peer) + { + logger.error('peer not found'); - case 'raisehand-message': - { - accept(); - const { peerName, raiseHandState } = request.data; + return; + } - logger.debug('Got raiseHandState from "%s"', peerName); + this._dispatch(stateActions.addFile(file)); - this._dispatch( - stateActions.setPeerRaiseHandState(peerName, raiseHandState)); - break; - } + this.notify(`${peer.appData.displayName} shared a file.`); - case 'chat-message-receive': - { - accept(); - - const { peerName, chatMessage } = request.data; - - logger.debug('Got chat from "%s"', peerName); - - this._dispatch( - stateActions.addResponseMessage({ ...chatMessage, peerName })); - - break; - } - - case 'chat-history-receive': - { - accept(); - - const { chatHistory } = request.data; - - if (chatHistory.length > 0) - { - logger.debug('Got chat history'); - this._dispatch( - stateActions.addChatHistory(chatHistory)); - } - - break; - } - - case 'file-receive': - { - accept(); - - const payload = request.data.file; - - this._dispatch(stateActions.addFile(payload)); - - this._dispatch(requestActions.notify({ - text : `${payload.name} shared a file` - })); - - break; - } - - case 'file-history-receive': - { - accept(); - - const files = request.data.fileHistory; - - if (files.length > 0) - { - logger.debug('Got files history'); - - this._dispatch(stateActions.addFileHistory(files)); - } - - break; - } - - default: - { - logger.error('unknown protoo method "%s"', request.method); - - reject(404, 'unknown method'); - } + if (!this._getState().toolarea.toolAreaOpen || + (this._getState().toolarea.toolAreaOpen && + this._getState().toolarea.currentToolTab !== 'files')) // Make sound + { + this._soundNotification(); } }); } - _joinRoom({ displayName, device }) + async _joinRoom({ displayName, device }) { logger.debug('_joinRoom()'); - // NOTE: We allow rejoining (room.join()) the same mediasoup Room when Protoo + // NOTE: We allow rejoining (room.join()) the same mediasoup Room when // WebSocket re-connects, so we must clean existing event listeners. Otherwise // they will be called twice after the reconnection. this._room.removeAllListeners(); @@ -1235,7 +1298,7 @@ export default class RoomClient logger.debug( 'sending mediasoup request [method:%s]:%o', request.method, request); - this._protoo.send('mediasoup-request', request) + this.sendRequest('mediasoup-request', request) .then(callback) .catch(errback); }); @@ -1246,7 +1309,7 @@ export default class RoomClient 'sending mediasoup notification [method:%s]:%o', notification.method, notification); - this._protoo.send('mediasoup-notification', notification) + this.sendRequest('mediasoup-notification', notification) .catch((error) => { logger.warn('could not send mediasoup notification:%o', error); @@ -1258,604 +1321,514 @@ export default class RoomClient logger.debug( 'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer); + this._soundNotification(); + this._handlePeer(peer); }); - this._room.join(this._peerName, { displayName, device }) - .then(() => + try + { + await this._room.join(this._peerName, { displayName, device }); + + this._sendTransport = + this._room.createTransport('send', { media: 'SEND_MIC_WEBCAM' }); + + this._sendTransport.on('close', (originator) => { - // Create Transport for sending. - this._sendTransport = - this._room.createTransport('send', { media: 'SEND_MIC_WEBCAM' }); - - this._sendTransport.on('close', (originator) => - { - logger.debug( - 'Transport "close" event [originator:%s]', originator); - }); - - // Create Transport for receiving. - this._recvTransport = - this._room.createTransport('recv', { media: 'RECV' }); - - this._recvTransport.on('close', (originator) => - { - logger.debug( - 'receiving Transport "close" event [originator:%s]', originator); - }); - }) - .then(() => - { - // Set our media capabilities. - this._dispatch(stateActions.setMediaCapabilities( - { - 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(() => - { - // Don't produce if explicitely requested to not to do it. - if (!this._produce) - return; - - // NOTE: Don't depend on this Promise to continue (so we don't do return). - Promise.resolve() - // Add our mic. - .then(() => - { - if (!this._room.canSend('audio')) - return; - - this._setMicProducer() - .catch(() => {}); - }) - // Add our webcam (unless the cookie says no). - .then(() => - { - if (!this._room.canSend('video')) - return; - - const devicesCookie = cookiesManager.getDevices(); - - if (!devicesCookie || devicesCookie.webcamEnabled) - this.enableWebcam(); - }); - }) - .then(() => - { - this._dispatch(stateActions.setRoomState('connected')); - - // Clean all the existing notifcations. - this._dispatch(stateActions.removeAllNotifications()); - - this.getChatHistory(); - this.getFileHistory(); - - this._dispatch(requestActions.notify( - { - text : 'You are in the room', - timeout : 5000 - })); - - const peers = this._room.peers; - - for (const peer of peers) - { - this._handlePeer(peer, { notify: false }); - } - }) - .catch((error) => - { - logger.error('_joinRoom() failed:%o', error); - - this._dispatch(requestActions.notify( - { - type : 'error', - text : `Could not join the room: ${error.toString()}` - })); - - this.close(); + logger.debug( + 'Transport "close" event [originator:%s]', originator); }); + + // Create Transport for receiving. + this._recvTransport = + this._room.createTransport('recv', { media: 'RECV' }); + + this._recvTransport.on('close', (originator) => + { + logger.debug( + 'receiving Transport "close" event [originator:%s]', originator); + }); + + // Set our media capabilities. + this._dispatch(stateActions.setMediaCapabilities( + { + 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() + })); + + // Don't produce if explicitely requested to not to do it. + if (this._produce) + { + if (this._room.canSend('audio')) + this._setMicProducer(); + + // Add our webcam (unless the cookie says no). + if (this._room.canSend('video')) + { + const devicesCookie = cookiesManager.getDevices(); + + if (!devicesCookie || devicesCookie.webcamEnabled) + this.enableWebcam(); + } + } + + this._dispatch(stateActions.setRoomState('connected')); + + // Clean all the existing notifcations. + this._dispatch(stateActions.removeAllNotifications()); + + this.getServerHistory(); + + this.notify('You have joined the room.'); + + this._spotlights.on('spotlights-updated', (spotlights) => + { + this._dispatch(stateActions.setSpotlights(spotlights)); + this.updateSpotlights(spotlights); + }); + + const peers = this._room.peers; + + for (const peer of peers) + { + this._handlePeer(peer, { notify: false }); + } + + this._spotlights.start(); + } + catch (error) + { + logger.error('_joinRoom() failed:%o', error); + + this.notify('An error occured while joining the room.'); + + this.close(); + } } - _setMicProducer() + async _setMicProducer() { if (!this._room.canSend('audio')) - { - return Promise.reject( - new Error('cannot send audio')); - } + throw new Error('cannot send audio'); if (this._micProducer) - { - return Promise.reject( - new Error('mic Producer already exists')); - } + throw new Error('mic Producer already exists'); let producer; - return Promise.resolve() - .then(() => - { - logger.debug('_setMicProducer() | calling _updateAudioDevices()'); + try + { + logger.debug('_setMicProducer() | calling getUserMedia()'); - return this._updateAudioDevices(); - }) - .then(() => - { - logger.debug('_setMicProducer() | calling getUserMedia()'); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - return navigator.mediaDevices.getUserMedia({ audio: true }); - }) - .then((stream) => - { - const track = stream.getAudioTracks()[0]; + const track = stream.getAudioTracks()[0]; - producer = this._room.createProducer(track, null, { source: 'mic' }); + producer = this._room.createProducer(track, null, { source: 'mic' }); - // No need to keep original track. - track.stop(); + // No need to keep original track. + track.stop(); - // Send it. - return producer.send(this._sendTransport); - }) - .then(() => - { - this._micProducer = producer; + // Send it. + await producer.send(this._sendTransport); - this._dispatch(stateActions.addProducer( - { - id : producer.id, - source : 'mic', - locallyPaused : producer.locallyPaused, - remotelyPaused : producer.remotelyPaused, - track : producer.track, - codec : producer.rtpParameters.codecs[0].name - })); + this._micProducer = producer; - producer.on('close', (originator) => + this._dispatch(stateActions.addProducer( { - logger.debug( - 'mic Producer "close" event [originator:%s]', originator); + id : producer.id, + source : 'mic', + locallyPaused : producer.locallyPaused, + remotelyPaused : producer.remotelyPaused, + track : producer.track, + codec : producer.rtpParameters.codecs[0].name + })); - this._micProducer = null; - this._dispatch(stateActions.removeProducer(producer.id)); - }); + logger.debug('_setMicProducer() | calling _updateAudioDevices()'); - producer.on('pause', (originator) => - { - logger.debug( - 'mic Producer "pause" event [originator:%s]', originator); + await this._updateAudioDevices(); - this._dispatch(stateActions.setProducerPaused(producer.id, originator)); - }); - - producer.on('resume', (originator) => - { - logger.debug( - 'mic Producer "resume" event [originator:%s]', originator); - - this._dispatch(stateActions.setProducerResumed(producer.id, originator)); - }); - - producer.on('handled', () => - { - logger.debug('mic Producer "handled" event'); - }); - - producer.on('unhandled', () => - { - logger.debug('mic Producer "unhandled" event'); - }); - }) - .then(() => + producer.on('close', (originator) => { - const stream = new MediaStream; + logger.debug( + 'mic Producer "close" event [originator:%s]', originator); - logger.debug('_setMicProducer() succeeded'); - stream.addTrack(producer.track); - if (!stream.getAudioTracks()[0]) - throw new Error('_setMicProducer(): given stream has no audio track'); - producer.hark = hark(stream, { play: false }); - - // eslint-disable-next-line no-unused-vars - producer.hark.on('volume_change', (dBs, threshold) => - { - // The exact formula to convert from dBs (-100..0) to linear (0..1) is: - // Math.pow(10, dBs / 20) - // However it does not produce a visually useful output, so let exagerate - // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to - // minimize component renderings. - let volume = Math.round(Math.pow(10, dBs / 85) * 10); - - if (volume === 1) - volume = 0; - - if (volume !== producer.volume) - { - producer.volume = volume; - this._dispatch(stateActions.setProducerVolume(producer.id, volume)); - } - }); - }) - .catch((error) => - { - logger.error('_setMicProducer() failed:%o', error); - - this._dispatch(requestActions.notify( - { - text : `Mic producer failed: ${error.name}:${error.message}` - })); - - if (producer) - producer.close(); - - throw error; + this._micProducer = null; + this._dispatch(stateActions.removeProducer(producer.id)); }); + + producer.on('pause', (originator) => + { + logger.debug( + 'mic Producer "pause" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerPaused(producer.id, originator)); + }); + + producer.on('resume', (originator) => + { + logger.debug( + 'mic Producer "resume" event [originator:%s]', originator); + + this._dispatch(stateActions.setProducerResumed(producer.id, originator)); + }); + + producer.on('handled', () => + { + logger.debug('mic Producer "handled" event'); + }); + + producer.on('unhandled', () => + { + logger.debug('mic Producer "unhandled" event'); + }); + + const harkStream = new MediaStream; + + harkStream.addTrack(producer.track); + if (!harkStream.getAudioTracks()[0]) + throw new Error('_setMicProducer(): given stream has no audio track'); + producer.hark = hark(harkStream, { play: false }); + + // eslint-disable-next-line no-unused-vars + producer.hark.on('volume_change', (dBs, threshold) => + { + // The exact formula to convert from dBs (-100..0) to linear (0..1) is: + // Math.pow(10, dBs / 20) + // However it does not produce a visually useful output, so let exagerate + // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to + // minimize component renderings. + let volume = Math.round(Math.pow(10, dBs / 85) * 10); + + if (volume === 1) + volume = 0; + + if (volume !== producer.volume) + { + producer.volume = volume; + this._dispatch(stateActions.setProducerVolume(producer.id, volume)); + } + }); + } + catch (error) + { + logger.error('_setMicProducer() failed:%o', error); + + this.notify('An error occured while accessing your microphone.'); + + if (producer) + producer.close(); + + throw error; + } } - _setScreenShareProducer() + async _setScreenShareProducer() { if (!this._room.canSend('video')) - { - return Promise.reject( - new Error('cannot send screen')); - } + throw new Error('cannot send screen'); let producer; - return Promise.resolve() - .then(() => - { - const available = this._screenSharing.isScreenShareAvailable() && - !this._screenSharing.needExtension(); + try + { + const available = this._screenSharing.isScreenShareAvailable() && + !this._screenSharing.needExtension(); - if (!available) - throw new Error('screen sharing not available'); + if (!available) + throw new Error('screen sharing not available'); - logger.debug('_setScreenShareProducer() | calling getUserMedia()'); + 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('trackended', (originator) => - { - logger.debug( - 'webcam Producer "trackended" event [originator:%s]', originator); - - this.disableScreenSharing(); - }); - - 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; + const stream = await this._screenSharing.start({ + width : 1280, + height : 720, + frameRate : 3 }); + + const track = stream.getVideoTracks()[0]; + + producer = this._room.createProducer( + track, { simulcast: false }, { source: 'screen' }); + + // No need to keep original track. + track.stop(); + + // Send it. + await producer.send(this._sendTransport); + + 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('trackended', (originator) => + { + logger.debug( + 'webcam Producer "trackended" event [originator:%s]', originator); + + this.disableScreenSharing(); + }); + + 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'); + }); + + logger.debug('_setScreenShareProducer() succeeded'); + } + catch (error) + { + logger.error('_setScreenShareProducer() failed:%o', error); + + if (error.name === 'NotAllowedError') // Request to share denied by user + { + this.notify('Request to start sharing your screen was denied.'); + } + else // Some other error + { + this.notify('An error occured while starting to share your screen.'); + } + + if (producer) + producer.close(); + + throw error; + } } - _setWebcamProducer() + async _setWebcamProducer() { if (!this._room.canSend('video')) - { - return Promise.reject( - new Error('cannot send video')); - } + throw new Error('cannot send video'); if (this._webcamProducer) - { - return Promise.reject( - new Error('webcam Producer already exists')); - } + throw new Error('webcam Producer already exists'); let producer; - return Promise.resolve() - .then(() => - { - const { device } = this._webcam; + try + { + logger.debug('_setWebcamProducer() | calling getUserMedia()'); - if (!device) - throw new Error('no webcam devices'); - - logger.debug('_setWebcamProducer() | calling getUserMedia()'); - - return navigator.mediaDevices.getUserMedia( + const stream = await navigator.mediaDevices.getUserMedia( + { + video : { - video : - { - deviceId : { exact: device.deviceId }, - ...VIDEO_CONSTRAINS - } - }); - }) - .then((stream) => + ...VIDEO_CONSTRAINS + } + }); + + const track = stream.getVideoTracks()[0]; + + producer = this._room.createProducer( + track, { simulcast: this._useSimulcast }, { source: 'webcam' }); + + // No need to keep original track. + track.stop(); + + // Send it. + await producer.send(this._sendTransport); + + this._webcamProducer = producer; + + this._dispatch(stateActions.addProducer( + { + id : producer.id, + source : 'webcam', + locallyPaused : producer.locallyPaused, + remotelyPaused : producer.remotelyPaused, + track : producer.track, + codec : producer.rtpParameters.codecs[0].name + })); + + logger.debug('_setWebcamProducer() | calling _updateWebcams()'); + await this._updateWebcams(); + + producer.on('close', (originator) => { - const track = stream.getVideoTracks()[0]; + logger.debug( + 'webcam Producer "close" event [originator:%s]', originator); - producer = this._room.createProducer( - track, { simulcast: this._useSimulcast }, { source: 'webcam' }); - - // No need to keep original track. - track.stop(); - - // Send it. - return producer.send(this._sendTransport); - }) - .then(() => - { - this._webcamProducer = producer; - - const { device } = this._webcam; - - this._dispatch(stateActions.addProducer( - { - id : producer.id, - source : 'webcam', - deviceLabel : device.label, - type : this._getWebcamType(device), - 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._webcamProducer = 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('_setWebcamProducer() succeeded'); - }) - .catch((error) => - { - logger.error('_setWebcamProducer() failed:%o', error); - - this._dispatch(requestActions.notify( - { - text : `Webcam producer failed: ${error.name}:${error.message}` - })); - - if (producer) - producer.close(); - - throw error; + this._webcamProducer = 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'); + }); + + logger.debug('_setWebcamProducer() succeeded'); + } + catch (error) + { + logger.error('_setWebcamProducer() failed:%o', error); + + this.notify('An error occured while accessing your camera.'); + + if (producer) + producer.close(); + + throw error; + } } - _updateAudioDevices() + async _updateAudioDevices() { logger.debug('_updateAudioDevices()'); // Reset the list. this._audioDevices = new Map(); - return Promise.resolve() - .then(() => + try + { + logger.debug('_updateAudioDevices() | calling enumerateDevices()'); + + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) { - logger.debug('_updateAudioDevices() | calling enumerateDevices()'); + if (device.kind !== 'audioinput') + continue; - return navigator.mediaDevices.enumerateDevices(); - }) - .then((devices) => - { - for (const device of devices) - { - if (device.kind !== 'audioinput') - continue; + device.value = device.deviceId; - device.value = device.deviceId; + this._audioDevices.set(device.deviceId, device); + } - this._audioDevices.set(device.deviceId, device); - } - }) - .then(() => - { - const array = Array.from(this._audioDevices.values()); - const len = array.length; - const currentAudioDeviceId = - this._audioDevice.device ? this._audioDevice.device.deviceId : undefined; + const array = Array.from(this._audioDevices.values()); + const len = array.length; + const currentAudioDeviceId = + this._audioDevice.device ? this._audioDevice.device.deviceId : undefined; - logger.debug('_updateAudioDevices() [audiodevices:%o]', array); + logger.debug('_updateAudioDevices() [audiodevices:%o]', array); - if (len === 0) - this._audioDevice.device = null; - else if (!this._audioDevices.has(currentAudioDeviceId)) - this._audioDevice.device = array[0]; + if (len === 0) + this._audioDevice.device = null; + else if (!this._audioDevices.has(currentAudioDeviceId)) + this._audioDevice.device = array[0]; + this._dispatch( + stateActions.setCanChangeAudioDevice(len >= 2)); + if (len >= 1) this._dispatch( - stateActions.setCanChangeWebcam(this._webcams.size >= 2)); - - this._dispatch( - stateActions.setCanChangeAudioDevice(len >= 2)); - if (len >= 1) - this._dispatch( - stateActions.setAudioDevices(this._audioDevices)); - }); + stateActions.setAudioDevices(this._audioDevices)); + } + catch (error) + { + logger.error('_updateAudioDevices() failed:%o', error); + } } - _updateWebcams() + async _updateWebcams() { logger.debug('_updateWebcams()'); // Reset the list. this._webcams = new Map(); - return Promise.resolve() - .then(() => - { - logger.debug('_updateWebcams() | calling enumerateDevices()'); - - return navigator.mediaDevices.enumerateDevices(); - }) - .then((devices) => - { - for (const device of devices) - { - if (device.kind !== 'videoinput') - continue; - - device.value = device.deviceId; - - this._webcams.set(device.deviceId, device); - } - }) - .then(() => - { - const array = Array.from(this._webcams.values()); - const len = array.length; - const currentWebcamId = - this._webcam.device ? this._webcam.device.deviceId : undefined; - - logger.debug('_updateWebcams() [webcams:%o]', array); - - if (len === 0) - this._webcam.device = null; - else if (!this._webcams.has(currentWebcamId)) - this._webcam.device = array[0]; - - this._dispatch( - stateActions.setCanChangeWebcam(this._webcams.size >= 2)); - - this._dispatch( - stateActions.setCanChangeWebcam(len >= 2)); - if (len >= 1) - this._dispatch( - stateActions.setWebcamDevices(this._webcams)); - }); - } - - _getWebcamType(device) - { - if (/(back|rear)/i.test(device.label)) + try { - logger.debug('_getWebcamType() | it seems to be a back camera'); + logger.debug('_updateWebcams() | calling enumerateDevices()'); - return 'back'; + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) + { + if (device.kind !== 'videoinput') + continue; + + device.value = device.deviceId; + + this._webcams.set(device.deviceId, device); + } + + const array = Array.from(this._webcams.values()); + const len = array.length; + const currentWebcamId = + this._webcam.device ? this._webcam.device.deviceId : undefined; + + logger.debug('_updateWebcams() [webcams:%o]', array); + + if (len === 0) + this._webcam.device = null; + else if (!this._webcams.has(currentWebcamId)) + this._webcam.device = array[0]; + + if (len >= 1) + this._dispatch( + stateActions.setWebcamDevices(this._webcams)); } - else + catch (error) { - logger.debug('_getWebcamType() | it seems to be a front camera'); - - return 'front'; + logger.error('_updateWebcams() failed:%o', error); } } @@ -1874,10 +1847,7 @@ export default class RoomClient if (notify) { - this._dispatch(requestActions.notify( - { - text : `${displayName} joined the room` - })); + this.notify(`${displayName} joined the room.`); } for (const consumer of peer.consumers) @@ -1895,10 +1865,7 @@ export default class RoomClient if (this._room.joined) { - this._dispatch(requestActions.notify( - { - text : `${peer.appData.displayName} left the room` - })); + this.notify(`${displayName} left the room.`); } }); @@ -2007,9 +1974,13 @@ export default class RoomClient // Receive the consumer (if we can). if (consumer.supported) { - // Pause it if video and we are in audio-only mode. - if (consumer.kind === 'video' && this._getState().me.audioOnly) - consumer.pause('audio-only-mode'); + if (consumer.kind === 'video' && + !this._spotlights.peerInSpotlights(consumer.peer.name)) + { // Start paused + logger.debug( + 'consumer paused by default'); + consumer.pause('not-speaker'); + } consumer.receive(this._recvTransport) .then((track) => diff --git a/app/lib/Spotlights.js b/app/lib/Spotlights.js new file mode 100644 index 0000000..8b91e99 --- /dev/null +++ b/app/lib/Spotlights.js @@ -0,0 +1,184 @@ +import { EventEmitter } from 'events'; +import Logger from './Logger'; + +const logger = new Logger('Spotlight'); + +export default class Spotlights extends EventEmitter +{ + constructor(maxSpotlights, room) + { + super(); + + this._room = room; + this._maxSpotlights = maxSpotlights; + this._peerList = []; + this._selectedSpotlights = []; + this._currentSpotlights = []; + this._started = false; + } + + start() + { + const peers = this._room.peers; + + for (const peer of peers) + { + this._handlePeer(peer); + } + + this._handleRoom(); + + this._started = true; + this._spotlightsUpdated(); + } + + peerInSpotlights(peerName) + { + if (this._started) + { + return this._currentSpotlights.indexOf(peerName) !== -1; + } + else + { + return false; + } + } + + setPeerSpotlight(peerName) + { + logger.debug('setPeerSpotlight() [peerName:"%s"]', peerName); + + const index = this._selectedSpotlights.indexOf(peerName); + + if (index !== -1) + { + this._selectedSpotlights = []; + } + else + { + this._selectedSpotlights = [ peerName ]; + } + + /* + if (index === -1) // We don't have this peer in the list, adding + { + this._selectedSpotlights.push(peerName); + } + else // We have this peer, remove + { + this._selectedSpotlights.splice(index, 1); + } + */ + + if (this._started) + this._spotlightsUpdated(); + } + + _handleRoom() + { + this._room.on('newpeer', (peer) => + { + logger.debug( + 'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer); + this._handlePeer(peer); + }); + } + + addSpeakerList(speakerList) + { + this._peerList = [ ...new Set([ ...speakerList, ...this._peerList ]) ]; + + if (this._started) + this._spotlightsUpdated(); + } + + _handlePeer(peer) + { + logger.debug('_handlePeer() [peerName:"%s"]', peer.name); + + if (this._peerList.indexOf(peer.name) === -1) // We don't have this peer in the list + { + peer.on('close', () => + { + let index = this._peerList.indexOf(peer.name); + + if (index !== -1) // We have this peer in the list, remove + { + this._peerList.splice(index, 1); + } + + index = this._selectedSpotlights.indexOf(peer.name); + + if (index !== -1) // We have this peer in the list, remove + { + this._selectedSpotlights.splice(index, 1); + } + + this._spotlightsUpdated(); + }); + + logger.debug('_handlePeer() | adding peer [peerName:"%s"]', peer.name); + + this._peerList.push(peer.name); + + this._spotlightsUpdated(); + } + } + + handleActiveSpeaker(peerName) + { + logger.debug('handleActiveSpeaker() [peerName:"%s"]', peerName); + + const index = this._peerList.indexOf(peerName); + + if (index > -1) + { + this._peerList.splice(index, 1); + this._peerList = [ peerName ].concat(this._peerList); + + this._spotlightsUpdated(); + } + } + + _spotlightsUpdated() + { + let spotlights; + + if (this._selectedSpotlights.length > 0) + { + spotlights = [ ...new Set([ ...this._selectedSpotlights, ...this._peerList ]) ]; + } + else + { + spotlights = this._peerList; + } + + if ( + !this._arraysEqual( + this._currentSpotlights, spotlights.slice(0, this._maxSpotlights) + ) + ) + { + logger.debug('_spotlightsUpdated() | spotlights updated, emitting'); + + this._currentSpotlights = spotlights.slice(0, this._maxSpotlights); + this.emit('spotlights-updated', this._currentSpotlights); + } + else + logger.debug('_spotlightsUpdated() | spotlights not updated'); + } + + _arraysEqual(arr1, arr2) + { + if (arr1.length !== arr2.length) + return false; + + for (let i = arr1.length; i--;) + { + if (arr1[i] !== arr2[i]) + return false; + } + + return true; + } +} diff --git a/app/lib/components/Chat/Chat.jsx b/app/lib/components/Chat/Chat.jsx index 898bf75..20f330c 100644 --- a/app/lib/components/Chat/Chat.jsx +++ b/app/lib/components/Chat/Chat.jsx @@ -34,6 +34,11 @@ class Chat extends Component autoFocus={autofocus} autoComplete='off' /> + ); @@ -53,7 +58,7 @@ Chat.propTypes = Chat.defaultProps = { senderPlaceHolder : 'Type a message...', - autofocus : true, + autofocus : false, displayName : null }; diff --git a/app/lib/components/Chat/MessageList.jsx b/app/lib/components/Chat/MessageList.jsx index e80e17b..43a9671 100644 --- a/app/lib/components/Chat/MessageList.jsx +++ b/app/lib/components/Chat/MessageList.jsx @@ -30,7 +30,7 @@ class MessageList extends Component return (
- { + { chatmessages.length > 0 ? chatmessages.map((message, i) => { const messageTime = new Date(message.time); @@ -61,6 +61,9 @@ class MessageList extends Component
); }) + :
+

No one has said anything yet...

+
} ); diff --git a/app/lib/components/ChatWidget.jsx b/app/lib/components/ChatWidget.jsx deleted file mode 100644 index 654b7ec..0000000 --- a/app/lib/components/ChatWidget.jsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import * as stateActions from '../redux/stateActions'; -import * as requestActions from '../redux/requestActions'; -import MessageList from './Chat/MessageList'; - -class ChatWidget extends Component -{ - componentWillReceiveProps(nextProps) - { - if (nextProps.chatmessages.length !== this.props.chatmessages.length) - if (!this.props.showChat) - this.props.increaseBadge(); - } - - render() - { - const { - senderPlaceHolder, - onSendMessage, - onToggleChat, - showChat, - disabledInput, - badge, - autofocus, - displayName - } = this.props; - - return ( -
- { - showChat && -
- -
{ onSendMessage(e, displayName); }} - > - -
-
- } - { -
- { - badge > 0 && {badge} - } -
- } -
- ); - } -} - -ChatWidget.propTypes = -{ - onToggleChat : PropTypes.func, - showChat : PropTypes.bool, - senderPlaceHolder : PropTypes.string, - onSendMessage : PropTypes.func, - disabledInput : PropTypes.bool, - badge : PropTypes.number, - autofocus : PropTypes.bool, - displayName : PropTypes.string, - chatmessages : PropTypes.arrayOf(PropTypes.object), - increaseBadge : PropTypes.func -}; - -ChatWidget.defaultProps = -{ - senderPlaceHolder : 'Type a message...', - autofocus : true -}; - -const mapStateToProps = (state) => -{ - return { - showChat : state.chatbehavior.showChat, - disabledInput : state.chatbehavior.disabledInput, - displayName : state.me.displayName, - badge : state.chatbehavior.badge, - chatmessages : state.chatmessages - }; -}; - -const mapDispatchToProps = (dispatch) => -{ - return { - onToggleChat : () => - { - dispatch(stateActions.toggleChat()); - }, - onSendMessage : (event, displayName) => - { - event.preventDefault(); - const userInput = event.target.message.value; - - if (userInput) - { - dispatch(stateActions.addUserMessage(userInput)); - dispatch(requestActions.sendChatMessage(userInput, displayName)); - } - event.target.message.value = ''; - }, - increaseBadge : () => - { - dispatch(stateActions.increaseBadge()); - } - }; -}; - -const ChatWidgetContainer = connect( - mapStateToProps, - mapDispatchToProps -)(ChatWidget); - -export default ChatWidgetContainer; diff --git a/app/lib/components/FileSharing/SharedFilesList.jsx b/app/lib/components/FileSharing/SharedFilesList.jsx index 9b2b6ce..28fc6b1 100644 --- a/app/lib/components/FileSharing/SharedFilesList.jsx +++ b/app/lib/components/FileSharing/SharedFilesList.jsx @@ -13,14 +13,21 @@ class SharedFilesList extends Component { render() { + const { sharing } = this.props; + return (
- {this.props.sharing.map((entry, i) => ( - - ))} + { sharing.length > 0 ? + sharing.map((entry, i) => ( + + )) + :
+

No one has shared files yet...

+
+ }
); } diff --git a/app/lib/components/Filmstrip.jsx b/app/lib/components/Filmstrip.jsx index 5a86b69..b0fe105 100644 --- a/app/lib/components/Filmstrip.jsx +++ b/app/lib/components/Filmstrip.jsx @@ -2,9 +2,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ResizeObserver from 'resize-observer-polyfill'; import { connect } from 'react-redux'; +import debounce from 'lodash/debounce'; import classnames from 'classnames'; -import * as stateActions from '../redux/stateActions'; +import * as requestActions from '../redux/requestActions'; import Peer from './Peer'; +import HiddenPeers from './HiddenPeers'; class Filmstrip extends Component { @@ -25,11 +27,11 @@ class Filmstrip extends Component // person has spoken yet, the first peer in the list of peers. getActivePeerName = () => { - if (this.props.selectedPeerName) + if (this.props.selectedPeerName) { return this.props.selectedPeerName; } - + if (this.state.lastSpeaker) { return this.state.lastSpeaker; @@ -51,7 +53,7 @@ class Filmstrip extends Component { let ratio = 4 / 3; - if (this.isSharingCamera(this.getActivePeerName())) + if (this.isSharingCamera(this.getActivePeerName())) { ratio *= 2; } @@ -59,7 +61,7 @@ class Filmstrip extends Component return ratio; }; - updateDimensions = () => + updateDimensions = debounce(() => { const container = this.activePeerContainer.current; @@ -69,16 +71,16 @@ class Filmstrip extends Component let width = container.clientWidth; - if (width / ratio > container.clientHeight) + if (width / ratio > container.clientHeight) { width = container.clientHeight * ratio; } - + this.setState({ width }); } - }; + }, 200); componentDidMount() { @@ -112,7 +114,7 @@ class Filmstrip extends Component render() { - const { peers, advancedMode } = this.props; + const { peers, advancedMode, spotlights, spotlightsLength } = this.props; const activePeerName = this.getActivePeerName(); @@ -137,25 +139,40 @@ class Filmstrip extends Component
- {Object.keys(peers).map((peerName) => ( -
this.props.setSelectedPeer(peerName)} - className={classnames('film', { - selected : this.props.selectedPeerName === peerName, - active : this.state.lastSpeaker === peerName - })} - > -
- -
-
- ))} + { + Object.keys(peers).map((peerName) => + { + return ( + spotlights.find((spotlightsElement) => spotlightsElement === peerName)? +
this.props.setSelectedPeer(peerName)} + className={classnames('film', { + selected : this.props.selectedPeerName === peerName, + active : this.state.lastSpeaker === peerName + })} + > +
+ +
+
+ :null + ); + }) + }
+
+ { (spotlightsLength:null + } +
+ ); } @@ -168,19 +185,28 @@ Filmstrip.propTypes = { consumers : PropTypes.object.isRequired, myName : PropTypes.string.isRequired, selectedPeerName : PropTypes.string, - setSelectedPeer : PropTypes.func.isRequired + setSelectedPeer : PropTypes.func.isRequired, + spotlightsLength : PropTypes.number, + spotlights : PropTypes.array.isRequired }; -const mapStateToProps = (state) => ({ - activeSpeakerName : state.room.activeSpeakerName, - selectedPeerName : state.room.selectedPeerName, - peers : state.peers, - consumers : state.consumers, - myName : state.me.name -}); +const mapStateToProps = (state) => +{ + const spotlightsLength = state.room.spotlights ? state.room.spotlights.length : 0; + + return { + activeSpeakerName : state.room.activeSpeakerName, + selectedPeerName : state.room.selectedPeerName, + peers : state.peers, + consumers : state.consumers, + myName : state.me.name, + spotlights : state.room.spotlights, + spotlightsLength + }; +}; const mapDispatchToProps = { - setSelectedPeer : stateActions.setSelectedPeer + setSelectedPeer : requestActions.setSelectedPeer }; export default connect( diff --git a/app/lib/components/FullScreen.js b/app/lib/components/FullScreen.js new file mode 100644 index 0000000..f3a0600 --- /dev/null +++ b/app/lib/components/FullScreen.js @@ -0,0 +1,107 @@ +const key = { + fullscreenEnabled : 0, + fullscreenElement : 1, + requestFullscreen : 2, + exitFullscreen : 3, + fullscreenchange : 4, + fullscreenerror : 5 +}; + +const webkit = [ + 'webkitFullscreenEnabled', + 'webkitFullscreenElement', + 'webkitRequestFullscreen', + 'webkitExitFullscreen', + 'webkitfullscreenchange', + 'webkitfullscreenerror' +]; + +const moz = [ + 'mozFullScreenEnabled', + 'mozFullScreenElement', + 'mozRequestFullScreen', + 'mozCancelFullScreen', + 'mozfullscreenchange', + 'mozfullscreenerror' +]; + +const ms = [ + 'msFullscreenEnabled', + 'msFullscreenElement', + 'msRequestFullscreen', + 'msExitFullscreen', + 'MSFullscreenChange', + 'MSFullscreenError' +]; + +export default class FullScreen +{ + constructor(document) + { + this.document = document; + this.vendor = ( + ('fullscreenEnabled' in this.document && Object.keys(key)) || + (webkit[0] in this.document && webkit) || + (moz[0] in this.document && moz) || + (ms[0] in this.document && ms) || + [] + ); + } + + requestFullscreen(element) + { + element[this.vendor[key.requestFullscreen]](); + } + + requestFullscreenFunction(element) + { + element[this.vendor[key.requestFullscreen]]; + } + + addEventListener(type, handler) + { + this.document.addEventListener(this.vendor[key[type]], handler); + } + + removeEventListener(type, handler) + { + this.document.removeEventListener(this.vendor[key[type]], handler); + } + + get exitFullscreen() + { + return this.document[this.vendor[key.exitFullscreen]].bind(this.document); + } + + get fullscreenEnabled() + { + return Boolean(this.document[this.vendor[key.fullscreenEnabled]]); + } + set fullscreenEnabled(val) {} + + get fullscreenElement() + { + return this.document[this.vendor[key.fullscreenElement]]; + } + set fullscreenElement(val) {} + + get onfullscreenchange() + { + return this.document[`on${this.vendor[key.fullscreenchange]}`.toLowerCase()]; + } + + set onfullscreenchange(handler) + { + this.document[`on${this.vendor[key.fullscreenchange]}`.toLowerCase()] = handler; + } + + get onfullscreenerror() + { + return this.document[`on${this.vendor[key.fullscreenerror]}`.toLowerCase()]; + } + + set onfullscreenerror(handler) + { + this.document[`on${this.vendor[key.fullscreenerror]}`.toLowerCase()] = handler; + } +} diff --git a/app/lib/components/FullScreenView.jsx b/app/lib/components/FullScreenView.jsx index d47ffda..f01ce7a 100644 --- a/app/lib/components/FullScreenView.jsx +++ b/app/lib/components/FullScreenView.jsx @@ -40,7 +40,7 @@ const FullScreenView = (props) =>
@@ -56,7 +56,6 @@ const FullScreenView = (props) => videoTrack={consumer ? consumer.track : null} videoVisible={consumerVisible} videoProfile={consumerProfile} - toggleFullscreen={() => toggleConsumerFullscreen(consumer)} />
); diff --git a/app/lib/components/FullView.jsx b/app/lib/components/FullView.jsx index 96b1f0f..ee64aa6 100644 --- a/app/lib/components/FullView.jsx +++ b/app/lib/components/FullView.jsx @@ -84,8 +84,7 @@ export default class FullView extends React.Component FullView.propTypes = { - videoTrack : PropTypes.any, - videoVisible : PropTypes.bool, - videoProfile : PropTypes.string, - toggleFullscreen : PropTypes.func.isRequired + videoTrack : PropTypes.any, + videoVisible : PropTypes.bool, + videoProfile : PropTypes.string }; diff --git a/app/lib/components/HiddenPeers.jsx b/app/lib/components/HiddenPeers.jsx new file mode 100644 index 0000000..5c34d70 --- /dev/null +++ b/app/lib/components/HiddenPeers.jsx @@ -0,0 +1,85 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import * as stateActions from '../redux/stateActions'; + +class HiddenPeers extends Component +{ + constructor(props) + { + super(props); + this.state = { className: '' }; + } + + componentDidUpdate(prevProps) + { + const { hiddenPeersCount } = this.props; + + if (hiddenPeersCount !== prevProps.hiddenPeersCount) + { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ className: 'pulse' }, () => + { + if (this.timeout) + { + clearTimeout(this.timeout); + } + + this.timeout = setTimeout(() => + { + this.setState({ className: '' }); + }, 2000); + }); + } + } + + render() + { + const { + hiddenPeersCount, + openUsersTab + } = this.props; + + return ( +
+
+
openUsersTab()}> +

+{hiddenPeersCount}
participant + {(hiddenPeersCount === 1) ? null : 's'} +

+
+
+
+ ); + } +} + +HiddenPeers.propTypes = +{ + hiddenPeersCount : PropTypes.number, + openUsersTab : PropTypes.func.isRequired +}; + +const mapDispatchToProps = (dispatch) => +{ + return { + openUsersTab : () => + { + dispatch(stateActions.openToolArea()); + dispatch(stateActions.setToolTab('users')); + } + }; +}; + +const HiddenPeersContainer = connect( + null, + mapDispatchToProps +)(HiddenPeers); + +export default HiddenPeersContainer; diff --git a/app/lib/components/Me.jsx b/app/lib/components/Me.jsx index dcedb94..a8de66d 100644 --- a/app/lib/components/Me.jsx +++ b/app/lib/components/Me.jsx @@ -15,14 +15,14 @@ class Me extends React.Component controlsVisible : false }; - handleMouseOver = () => + handleMouseOver = () => { this.setState({ controlsVisible : true }); }; - handleMouseOut = () => + handleMouseOut = () => { this.setState({ controlsVisible : false @@ -108,23 +108,29 @@ class Me extends React.Component >
{connected ? -
+
{ micState === 'on' ? onMuteMic() : onUnmuteMic(); }} /> - +
{ @@ -161,15 +167,6 @@ class Me extends React.Component
:null } - - {this._tooltip ? - - :null - }
); } diff --git a/app/lib/components/ParticipantList/ListMe.jsx b/app/lib/components/ParticipantList/ListMe.jsx index 6b86025..460c738 100644 --- a/app/lib/components/ParticipantList/ListMe.jsx +++ b/app/lib/components/ParticipantList/ListMe.jsx @@ -35,4 +35,4 @@ const mapStateToProps = (state) => ({ export default connect( mapStateToProps -)(ListMe); \ No newline at end of file +)(ListMe); diff --git a/app/lib/components/ParticipantList/ListPeer.jsx b/app/lib/components/ParticipantList/ListPeer.jsx index fec2269..82e252c 100644 --- a/app/lib/components/ParticipantList/ListPeer.jsx +++ b/app/lib/components/ParticipantList/ListPeer.jsx @@ -10,12 +10,9 @@ const ListPeer = (props) => const { peer, micConsumer, - webcamConsumer, screenConsumer, onMuteMic, onUnmuteMic, - onDisableWebcam, - onEnableWebcam, onDisableScreen, onEnableScreen } = props; @@ -26,12 +23,6 @@ const ListPeer = (props) => !micConsumer.remotelyPaused ); - const videoVisible = ( - Boolean(webcamConsumer) && - !webcamConsumer.locallyPaused && - !webcamConsumer.remotelyPaused - ); - const screenVisible = ( Boolean(screenConsumer) && !screenConsumer.locallyPaused && @@ -61,6 +52,9 @@ const ListPeer = (props) => :null }
+
+
+
{ screenConsumer ?
off : !micEnabled, disabled : peer.peerAudioInProgress })} - style={{ opacity : micEnabled && micConsumer ? (micConsumer.volume/10) - + 0.2 :1 }} onClick={(e) => { e.stopPropagation(); micEnabled ? onMuteMic(peer.name) : onUnmuteMic(peer.name); }} /> - -
- { - e.stopPropagation(); - videoVisible ? - onDisableWebcam(peer.name) : onEnableWebcam(peer.name); - }} - />
); diff --git a/app/lib/components/ParticipantList/ParticipantList.jsx b/app/lib/components/ParticipantList/ParticipantList.jsx index 5f56983..e9f8492 100644 --- a/app/lib/components/ParticipantList/ParticipantList.jsx +++ b/app/lib/components/ParticipantList/ParticipantList.jsx @@ -2,37 +2,73 @@ import React from 'react'; import { connect } from 'react-redux'; import classNames from 'classnames'; import * as appPropTypes from '../appPropTypes'; -import * as stateActions from '../../redux/stateActions'; +import * as requestActions from '../../redux/requestActions'; import PropTypes from 'prop-types'; import ListPeer from './ListPeer'; import ListMe from './ListMe'; -const ParticipantList = ({ advancedMode, peers, setSelectedPeer, selectedPeerName }) => ( -
-
    - +const ParticipantList = + ({ + advancedMode, + peers, + setSelectedPeer, + selectedPeerName, + spotlights + }) => ( +
    +
      +
    • Me:
    • + +
    +
    +
      +
    • Participants in Spotlight:
    • + {peers.filter((peer) => + { + return (spotlights.find((spotlight) => + { return (spotlight === peer.name); })); + }).map((peer) => ( +
    • setSelectedPeer(peer.name)} + > + +
    • + ))} +
    +
    +
      +
    • Passive Participants:
    • + {peers.filter((peer) => + { + return !(spotlights.find((spotlight) => + { return (spotlight === peer.name); })); + }).map((peer) => ( +
    • setSelectedPeer(peer.name)} + > + +
    • + ))} +
    - {peers.map((peer) => ( -
  • setSelectedPeer(peer.name)} - > - -
  • - ))} -
-
-); +
+ ); ParticipantList.propTypes = { advancedMode : PropTypes.bool, peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired, setSelectedPeer : PropTypes.func.isRequired, - selectedPeerName : PropTypes.string + selectedPeerName : PropTypes.string, + spotlights : PropTypes.array.isRequired }; const mapStateToProps = (state) => @@ -41,12 +77,13 @@ const mapStateToProps = (state) => return { peers : peersArray, - selectedPeerName : state.room.selectedPeerName + selectedPeerName : state.room.selectedPeerName, + spotlights : state.room.spotlights }; }; const mapDispatchToProps = { - setSelectedPeer : stateActions.setSelectedPeer + setSelectedPeer : requestActions.setSelectedPeer }; const ParticipantListContainer = connect( diff --git a/app/lib/components/Peer.jsx b/app/lib/components/Peer.jsx index 2aec88a..7a4777f 100644 --- a/app/lib/components/Peer.jsx +++ b/app/lib/components/Peer.jsx @@ -38,12 +38,10 @@ class Peer extends Component screenConsumer, onMuteMic, onUnmuteMic, - onDisableWebcam, - onEnableWebcam, - onDisableScreen, - onEnableScreen, toggleConsumerFullscreen, - style + toggleConsumerWindow, + style, + windowConsumer } = this.props; const micEnabled = ( @@ -90,6 +88,13 @@ class Peer extends Component :null } + {!videoVisible ? +
+

this video is paused

+
+ :null + } +
{peer.raiseHandState ? @@ -124,16 +129,14 @@ class Peer extends Component />
{ e.stopPropagation(); - videoVisible ? - onDisableWebcam(peer.name) : onEnableWebcam(peer.name); + toggleConsumerWindow(webcamConsumer); }} /> @@ -146,10 +149,10 @@ class Peer extends Component }} />
+
{ e.stopPropagation(); - screenVisible ? - onDisableScreen(peer.name) : onEnableScreen(peer.name); + toggleConsumerWindow(screenConsumer); }} /> @@ -211,15 +209,13 @@ Peer.propTypes = micConsumer : appPropTypes.Consumer, webcamConsumer : appPropTypes.Consumer, screenConsumer : appPropTypes.Consumer, + windowConsumer : PropTypes.number, onMuteMic : PropTypes.func.isRequired, onUnmuteMic : PropTypes.func.isRequired, - onEnableWebcam : PropTypes.func.isRequired, - onDisableWebcam : PropTypes.func.isRequired, streamDimensions : PropTypes.object, style : PropTypes.object, - onEnableScreen : PropTypes.func.isRequired, - onDisableScreen : PropTypes.func.isRequired, - toggleConsumerFullscreen : PropTypes.func.isRequired + toggleConsumerFullscreen : PropTypes.func.isRequired, + toggleConsumerWindow : PropTypes.func.isRequired }; const mapStateToProps = (state, { name }) => @@ -238,7 +234,8 @@ const mapStateToProps = (state, { name }) => peer, micConsumer, webcamConsumer, - screenConsumer + screenConsumer, + windowConsumer : state.room.windowConsumer }; }; @@ -253,27 +250,15 @@ const mapDispatchToProps = (dispatch) => { dispatch(requestActions.unmutePeerAudio(peerName)); }, - onEnableWebcam : (peerName) => - { - - dispatch(requestActions.resumePeerVideo(peerName)); - }, - onDisableWebcam : (peerName) => - { - dispatch(requestActions.pausePeerVideo(peerName)); - }, - onEnableScreen : (peerName) => - { - dispatch(requestActions.resumePeerScreen(peerName)); - }, - onDisableScreen : (peerName) => - { - dispatch(requestActions.pausePeerScreen(peerName)); - }, toggleConsumerFullscreen : (consumer) => { if (consumer) dispatch(stateActions.toggleConsumerFullscreen(consumer.id)); + }, + toggleConsumerWindow : (consumer) => + { + if (consumer) + dispatch(stateActions.toggleConsumerWindow(consumer.id)); } }; }; diff --git a/app/lib/components/PeerAudio/AudioPeer.jsx b/app/lib/components/PeerAudio/AudioPeer.jsx new file mode 100644 index 0000000..ad1a3b6 --- /dev/null +++ b/app/lib/components/PeerAudio/AudioPeer.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import * as appPropTypes from '../appPropTypes'; +import PeerAudio from './PeerAudio'; + +const AudioPeer = ({ micConsumer }) => +{ + return ( + + ); +}; + +AudioPeer.propTypes = +{ + micConsumer : appPropTypes.Consumer, + name : PropTypes.string +}; + +const mapStateToProps = (state, { name }) => +{ + const peer = state.peers[name]; + const consumersArray = peer.consumers + .map((consumerId) => state.consumers[consumerId]); + const micConsumer = + consumersArray.find((consumer) => consumer.source === 'mic'); + + return { + micConsumer + }; +}; + +const AudioPeerContainer = connect( + mapStateToProps +)(AudioPeer); + +export default AudioPeerContainer; diff --git a/app/lib/components/PeerAudio/AudioPeers.jsx b/app/lib/components/PeerAudio/AudioPeers.jsx new file mode 100644 index 0000000..3dce02e --- /dev/null +++ b/app/lib/components/PeerAudio/AudioPeers.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import * as appPropTypes from '../appPropTypes'; +import AudioPeer from './AudioPeer'; + +const AudioPeers = ({ peers }) => +{ + return ( +
+ { + peers.map((peer) => + { + return ( + + ); + }) + } +
+ ); +}; + +AudioPeers.propTypes = + { + peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired + }; + +const mapStateToProps = (state) => +{ + const peers = Object.values(state.peers); + + return { + peers + }; +}; + +const AudioPeersContainer = connect( + mapStateToProps +)(AudioPeers); + +export default AudioPeersContainer; diff --git a/app/lib/components/PeerAudio/PeerAudio.jsx b/app/lib/components/PeerAudio/PeerAudio.jsx new file mode 100644 index 0000000..871c8c4 --- /dev/null +++ b/app/lib/components/PeerAudio/PeerAudio.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class PeerAudio extends React.Component +{ + constructor(props) + { + super(props); + + // Latest received audio track. + // @type {MediaStreamTrack} + this._audioTrack = null; + } + + render() + { + return ( +
); } @@ -131,20 +148,27 @@ Peers.propTypes = advancedMode : PropTypes.bool, peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired, boxes : PropTypes.number, - activeSpeakerName : PropTypes.string + activeSpeakerName : PropTypes.string, + selectedPeerName : PropTypes.string, + spotlightsLength : PropTypes.number, + spotlights : PropTypes.array.isRequired }; const mapStateToProps = (state) => { const peers = Object.values(state.peers); - - const boxes = peers.length + Object.values(state.consumers) + const spotlights = state.room.spotlights; + const spotlightsLength = spotlights ? state.room.spotlights.length : 0; + const boxes = spotlightsLength + Object.values(state.consumers) .filter((consumer) => consumer.source === 'screen').length; return { peers, boxes, - activeSpeakerName : state.room.activeSpeakerName + activeSpeakerName : state.room.activeSpeakerName, + selectedPeerName : state.room.selectedPeerName, + spotlights, + spotlightsLength }; }; diff --git a/app/lib/components/Room.jsx b/app/lib/components/Room.jsx index 8232103..47a5a44 100644 --- a/app/lib/components/Room.jsx +++ b/app/lib/components/Room.jsx @@ -4,16 +4,19 @@ import ReactTooltip from 'react-tooltip'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import CopyToClipboard from 'react-copy-to-clipboard'; +import CookieConsent from 'react-cookie-consent'; import * as appPropTypes from './appPropTypes'; import * as requestActions from '../redux/requestActions'; import * as stateActions from '../redux/stateActions'; import { Appear } from './transitions'; import Me from './Me'; import Peers from './Peers'; +import AudioPeers from './PeerAudio/AudioPeers'; import Notifications from './Notifications'; -import ToolAreaButton from './ToolArea/ToolAreaButton'; +// import ToolAreaButton from './ToolArea/ToolAreaButton'; import ToolArea from './ToolArea/ToolArea'; import FullScreenView from './FullScreenView'; +import VideoWindow from './VideoWindow/VideoWindow'; import Draggable from 'react-draggable'; import { idle } from '../utils'; import Sidebar from './Sidebar'; @@ -32,16 +35,16 @@ class Room extends React.Component * given amount of time has passed since the * last time the cursor was moved. */ - waitForHide = idle(() => + waitForHide = idle(() => { this.props.setToolbarsVisible(false); }, TIMEOUT); - handleMovement = () => + handleMovement = () => { // If the toolbars were hidden, show them again when // the user moves their cursor. - if (!this.props.room.toolbarsVisible) + if (!this.props.room.toolbarsVisible) { this.props.setToolbarsVisible(true); } @@ -65,7 +68,6 @@ class Room extends React.Component { const { room, - toolAreaOpen, amActiveSpeaker, onRoomLinkCopy } = this.props; @@ -81,11 +83,19 @@ class Room extends React.Component
- -
- + + This website uses cookies to enhance the user experience. + - + + + + +
+
+ + + {room.advancedMode ?
@@ -94,7 +104,7 @@ class Room extends React.Component
:null } - +
@@ -155,16 +165,8 @@ class Room extends React.Component delayHide={100} />
-
- {toolAreaOpen ? - - :null - } -
+ +
diff --git a/app/lib/components/ScreenView.jsx b/app/lib/components/ScreenView.jsx index b95be2b..719c406 100644 --- a/app/lib/components/ScreenView.jsx +++ b/app/lib/components/ScreenView.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import Spinner from 'react-spinner'; -export default class PeerView extends React.Component +export default class ScreenView extends React.Component { constructor(props) { @@ -157,7 +157,7 @@ export default class PeerView extends React.Component } } -PeerView.propTypes = +ScreenView.propTypes = { isMe : PropTypes.bool, advancedMode : PropTypes.bool, diff --git a/app/lib/components/Settings.jsx b/app/lib/components/Settings.jsx index d0d3572..394887a 100644 --- a/app/lib/components/Settings.jsx +++ b/app/lib/components/Settings.jsx @@ -5,6 +5,7 @@ import * as requestActions from '../redux/requestActions'; import * as stateActions from '../redux/stateActions'; import PropTypes from 'prop-types'; import Dropdown from 'react-dropdown'; +import ReactTooltip from 'react-tooltip'; const modes = [ { value : 'democratic', @@ -22,12 +23,6 @@ const Settings = ({ }) => { let webcams; - let webcamText; - - if (me.canChangeWebcam) - webcamText = 'Select camera'; - else - webcamText = 'Unable to select camera'; if (me.webcamDevices) webcams = Array.from(me.webcamDevices.values()); @@ -51,13 +46,12 @@ const Settings = ({
handleChangeWebcam(webcam.value)} - placeholder={webcamText} + placeholder={'Select camera'} /> - + handleChangeAudioDevice(device.value)} placeholder={audioDevicesText} /> - - - +
+ + +
- handleChangeMode(mode.value)} - /> +
+ handleChangeMode(mode.value)} + /> +
); diff --git a/app/lib/components/Sidebar.jsx b/app/lib/components/Sidebar.jsx index a84d102..fc9f9ae 100644 --- a/app/lib/components/Sidebar.jsx +++ b/app/lib/components/Sidebar.jsx @@ -4,46 +4,52 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import * as appPropTypes from './appPropTypes'; import * as requestActions from '../redux/requestActions'; -import fscreen from 'fscreen'; +import FullScreen from './FullScreen'; class Sidebar extends Component { - state = { - fullscreen : false - }; + constructor(props) + { + super(props); + + this.fullscreen = new FullScreen(document); + this.state = { + fullscreen : false + }; + } handleToggleFullscreen = () => { - if (fscreen.fullscreenElement) + if (this.fullscreen.fullscreenElement) { - fscreen.exitFullscreen(); + this.fullscreen.exitFullscreen(); } else { - fscreen.requestFullscreen(document.documentElement); + this.fullscreen.requestFullscreen(document.documentElement); } }; handleFullscreenChange = () => { this.setState({ - fullscreen : fscreen.fullscreenElement !== null + fullscreen : this.fullscreen.fullscreenElement !== null }); }; componentDidMount() { - if (fscreen.fullscreenEnabled) + if (this.fullscreen.fullscreenEnabled) { - fscreen.addEventListener('fullscreenchange', this.handleFullscreenChange); + this.fullscreen.addEventListener('fullscreenchange', this.handleFullscreenChange); } } componentWillUnmount() { - if (fscreen.fullscreenEnabled) + if (this.fullscreen.fullscreenEnabled) { - fscreen.removeEventListener('fullscreenchange', this.handleFullscreenChange); + this.fullscreen.removeEventListener('fullscreenchange', this.handleFullscreenChange); } } @@ -85,13 +91,14 @@ class Sidebar extends Component })} data-component='Sidebar' > - {fscreen.fullscreenEnabled && ( + {this.fullscreen.fullscreenEnabled && (
)} @@ -99,6 +106,7 @@ class Sidebar extends Component
{ @@ -131,6 +139,7 @@ class Sidebar extends Component
@@ -140,6 +149,7 @@ class Sidebar extends Component
@@ -150,6 +160,7 @@ class Sidebar extends Component disabled : me.raiseHandInProgress })} data-tip='Raise hand' + data-place='right' data-type='dark' onClick={() => onToggleHand(!me.raiseHand)} /> @@ -157,6 +168,7 @@ class Sidebar extends Component
onLeaveMeeting()} /> diff --git a/app/lib/components/ToolArea/TabHeader.jsx b/app/lib/components/ToolArea/TabHeader.jsx new file mode 100644 index 0000000..a9514b1 --- /dev/null +++ b/app/lib/components/ToolArea/TabHeader.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +import * as stateActions from '../../redux/stateActions'; + +const TabHeader = ({ currentToolTab, setToolTab, id, name, badge }) => ( +
setToolTab(id)} + > + {name} + + {badge > 0 && ( + {badge} + )} +
+); + +TabHeader.propTypes = { + currentToolTab : PropTypes.string.isRequired, + setToolTab : PropTypes.func.isRequired, + id : PropTypes.string.isRequired, + name : PropTypes.string.isRequired, + badge : PropTypes.number +}; + +const mapStateToProps = (state) => ({ + currentToolTab : state.toolarea.currentToolTab +}); + +const mapDispatchToProps = { + setToolTab : stateActions.setToolTab +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TabHeader); \ No newline at end of file diff --git a/app/lib/components/ToolArea/ToolArea.jsx b/app/lib/components/ToolArea/ToolArea.jsx index 19545c0..5165796 100644 --- a/app/lib/components/ToolArea/ToolArea.jsx +++ b/app/lib/components/ToolArea/ToolArea.jsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import * as toolTabActions from '../../redux/stateActions'; +import classNames from 'classnames'; +import * as stateActions from '../../redux/stateActions'; import ParticipantList from '../ParticipantList/ParticipantList'; import Chat from '../Chat/Chat'; import Settings from '../Settings'; import FileSharing from '../FileSharing'; +import TabHeader from './TabHeader'; class ToolArea extends React.Component { @@ -18,88 +20,80 @@ class ToolArea extends React.Component { const { currentToolTab, + toolAreaOpen, unreadMessages, unreadFiles, - setToolTab + toggleToolArea, + unread } = this.props; + const VisibleTab = { + chat : Chat, + files : FileSharing, + users : ParticipantList, + settings : Settings + }[currentToolTab]; + return ( -
-
- - { - setToolTab('chat'); - }} - checked={currentToolTab === 'chat'} - /> -