diff --git a/.gitignore b/.gitignore index 122f774..ded69d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ node_modules/ /app/build/ -/app/public/config.js +/app/public/config/config.js /app/public/images/logo.* /server/config/ !/server/config/config.example.js /server/public/ /server/certs/ !/server/certs/mediasoup-demo.localhost.* +.vscode \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f982c1..e6b2f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +### 3.0 +* Updated to mediasoup v3 +* Replace lib "passport-datporten" with "openid-client" (a general OIDC certified client) + - OpenID Connect discovery + - Auth code flow +* Add spdy http2 support. + - Notice it does not supports node 11.x +* Updated to Material UI v4 + + ### 2.0 +* Material UI +* Separate settings for lastN for desktop and mobile + + ### 1.2 +* Add Lock Room feature +* Fix suspended Web Audio context / fixed delayed getUsermedia +* Added support for the new getdisplaymedia API in Chrome 72 + ### 1.1 * Moved Filesharing code out from React code to RoomClient * Major cleanup of CSS. Variables for most colors and sizes exposed in :root diff --git a/README.md b/README.md index 88bb10b..166629e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Try it online at https://letsmeet.no. You can add /roomname to the URL for speci * Chat * Screen sharing * File sharing -* Different video layouts +* Different layouts There is also a SIP gateway that can be found [here](https://github.com/havfo/multiparty-meeting-sipgw). To try it, call: roomname@letsmeet.no. @@ -25,16 +25,16 @@ $ git clone https://github.com/havfo/multiparty-meeting.git $ cd multiparty-meeting ``` -* Copy `server/config.example.js` to `server/config.js` : +* Copy `server/config/config.example.js` to `server/config/config.js` : ```bash -$ cp server/config.example.js server/config.js +$ cp server/config/config.example.js server/config/config.js ``` -* Copy `app/public/config.example.js` to `app/public/config.js` : +* Copy `app/public/config/config.example.js` to `app/public/config/config.js` : ```bash -$ cp app/public/config.example.js app/public/config.js +$ cp app/public/config/config.example.js app/public/config/config.js ``` * Edit your two `config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, etc). diff --git a/app/package.json b/app/package.json index 05a3baf..b85d265 100644 --- a/app/package.json +++ b/app/package.json @@ -1,47 +1,43 @@ { "name": "multiparty-meeting", - "version": "2.0.0", + "version": "3.0.0", "private": true, "description": "multiparty meeting service", "author": "Håvar Aambø Fosstveit ", "license": "MIT", "dependencies": { - "@material-ui/core": "^3.9.2", - "@material-ui/icons": "^3.0.2", + "@material-ui/core": "^4.1.2", + "@material-ui/icons": "^4.2.1", + "bowser": "^2.4.0", "create-torrent": "^3.33.0", "domready": "^1.0.8", "file-saver": "^2.0.1", "hark": "^1.2.3", - "js-cookie": "^2.2.0", "marked": "^0.6.1", - "mediasoup-client": "^2.4.10", + "mediasoup-client": "^3.0.6", "notistack": "^0.5.1", "prop-types": "^15.7.2", "random-string": "^0.2.0", "react": "^16.8.5", "react-cookie-consent": "^2.2.2", "react-dom": "^16.8.5", - "react-draggable": "^3.2.1", "react-redux": "^6.0.1", "react-scripts": "2.1.8", - "react-tooltip": "^3.10.0", "redux": "^4.0.1", "redux-logger": "^3.0.6", "redux-persist": "^5.10.0", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.1", "riek": "^1.1.0", "socket.io-client": "^2.2.0", "source-map-explorer": "^1.8.0", - "url-parse": "^1.4.4", "webtorrent": "^0.103.1" }, "scripts": { "analyze-main": "source-map-explorer build/static/js/main.*", "analyze-chunk": "source-map-explorer build/static/js/2.*", "start": "HTTPS=true PORT=4443 react-scripts start", - "build": "react-scripts build && rm -rf ../server/public/* && cp -r build/* ../server/public/", + "build": "react-scripts build && mkdir -p ../server/public && rm -rf ../server/public/* && cp -r build/* ../server/public/", "test": "react-scripts test", "eject": "react-scripts eject" }, @@ -169,7 +165,12 @@ "no-case-declarations": 2, "no-catch-shadow": 2, "no-class-assign": 2, - "no-confusing-arrow": ["error", {"allowParens": true}], + "no-confusing-arrow": [ + "error", + { + "allowParens": true + } + ], "no-console": 2, "no-const-assign": 2, "no-debugger": 2, diff --git a/app/public/config.example.js b/app/public/config/config.example.js similarity index 100% rename from app/public/config.example.js rename to app/public/config/config.example.js diff --git a/app/public/index.html b/app/public/index.html index dc9e2f3..86b1003 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -9,14 +9,13 @@ - - + Multiparty Meeting - + diff --git a/app/public/robots.txt b/app/public/robots.txt new file mode 100644 index 0000000..21fed79 --- /dev/null +++ b/app/public/robots.txt @@ -0,0 +1,3 @@ +# Allow crawling of all content +User-agent: * +Disallow: \ No newline at end of file diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 4a90984..aea8f94 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -20,7 +20,7 @@ const { const logger = new Logger('RoomClient'); -let ROOM_OPTIONS = +const ROOM_OPTIONS = { requestTimeout : requestTimeout, transportOptions : transportOptions, @@ -56,11 +56,14 @@ const VIDEO_CONSTRAINS = } }; -let store; +const VIDEO_ENCODINGS = +[ + { maxBitrate: 180000, scaleResolutionDownBy: 4 }, + { maxBitrate: 360000, scaleResolutionDownBy: 2 }, + { maxBitrate: 1500000, scaleResolutionDownBy: 1 } +]; -const AudioContext = window.AudioContext // Default - || window.webkitAudioContext // Safari and old versions of Chrome - || false; +let store; export default class RoomClient { @@ -74,13 +77,13 @@ export default class RoomClient } constructor( - { roomId, peerName, device, useSimulcast, produce }) + { roomId, peerId, device, useSimulcast, produce, consume, forceTcp }) { logger.debug( - 'constructor() [roomId:"%s", peerName:"%s", device:%s]', - roomId, peerName, device.flag); + 'constructor() [roomId: "%s", peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", consume: "%s", forceTcp: "%s"]', + roomId, peerId, device.flag, useSimulcast, produce, consume, forceTcp); - this._signalingUrl = getSignalingUrl(peerName, roomId); + this._signalingUrl = getSignalingUrl(peerId, roomId); // window element to external login site this._loginWindow = null; @@ -91,6 +94,12 @@ export default class RoomClient // Whether we should produce. this._produce = produce; + // Whether we should consume. + this._consume = consume; + + // Wheter we force TCP + this._forceTcp = forceTcp; + // Torrent support this._torrentSupport = WebTorrent.WEBRTC_SUPPORT; @@ -101,23 +110,21 @@ export default class RoomClient this._device = device; // My peer name. - this._peerName = peerName; + this._peerId = peerId; // Alert sound this._soundAlert = new Audio('/sounds/notify.mp3'); - if (AudioContext) - { - this._audioContext = new AudioContext(); - } - // Socket.io peer connection this._signalingSocket = null; - // The mediasoup room instance - this._room = null; + // The room ID this._roomId = roomId; + // mediasoup-client Device instance. + // @type {mediasoupClient.Device} + this._mediasoupDevice = null; + this._doneJoining = false; // Our WebTorrent client @@ -147,6 +154,9 @@ export default class RoomClient // Local mic mediasoup Producer. this._micProducer = null; + // Local mic hark + this._hark = null; + // Local webcam mediasoup Producer. this._webcamProducer = null; @@ -156,6 +166,10 @@ export default class RoomClient this._audioDevices = {}; + // mediasoup Consumers. + // @type {Map} + this._consumers = new Map(); + this._screenSharing = ScreenShare.create(device); this._screenSharingProducer = null; @@ -174,12 +188,14 @@ export default class RoomClient logger.debug('close()'); - // Leave the mediasoup Room. - this._room.leave(); + this._signalingSocket.close(); - // Close signaling Peer (wait a bit so mediasoup-client can send - // the 'leaveRoom' notification). - setTimeout(() => this._signalingSocket.close(), 250); + // Close mediasoup Transports. + if (this._sendTransport) + this._sendTransport.close(); + + if (this._recvTransport) + this._recvTransport.close(); store.dispatch(stateActions.setRoomState('closed')); } @@ -204,28 +220,61 @@ export default class RoomClient case 'a': // Activate advanced mode { store.dispatch(stateActions.toggleAdvancedMode()); - this.notify('Toggled advanced mode.'); + store.dispatch(requestActions.notify( + { + text : 'Toggled advanced mode.' + })); break; } case '1': // Set democratic view { store.dispatch(stateActions.setDisplayMode('democratic')); - this.notify('Changed layout to democratic view.'); + store.dispatch(requestActions.notify( + { + text : 'Changed layout to democratic view.' + })); break; } case '2': // Set filmstrip view { store.dispatch(stateActions.setDisplayMode('filmstrip')); - this.notify('Changed layout to filmstrip view.'); + store.dispatch(requestActions.notify( + { + text : 'Changed layout to filmstrip view.' + })); break; } case 'm': // Toggle microphone { - this.toggleMic(); - this.notify('Muted/unmuted your microphone.'); + if (this._micProducer) + { + if (this._micProducer.paused) + { + this._micProducer.resume(); + + store.dispatch(requestActions.notify( + { + text : 'Unmuted your microphone.' + })); + } + else + { + this._micProducer.pause(); + + store.dispatch(requestActions.notify( + { + text : 'Muted your microphone.' + })); + } + } + else + { + this.enableMic(); + } + break; } @@ -247,20 +296,23 @@ export default class RoomClient await this._updateAudioDevices(); await this._updateWebcams(); - this.notify('Your devices changed, configure your devices in the settings dialog.'); + store.dispatch(requestActions.notify( + { + text : 'Your devices changed, configure your devices in the settings dialog.' + })); }); } login() { - const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`; + const url = `/auth/login?roomId=${this._roomId}&peerId=${this._peerId}`; this._loginWindow = window.open(url, 'loginWindow'); } logout() { - window.location = '/logout'; + window.location = '/auth/logout'; } closeLoginWindow() @@ -324,17 +376,21 @@ export default class RoomClient } else { - this._signalingSocket.emit(method, data, this.timeoutCallback((err, response) => - { - if (err) + this._signalingSocket.emit( + 'request', + { method, data }, + this.timeoutCallback((err, response) => { - reject(err); - } - else - { - resolve(response); - } - })); + if (err) + { + reject(err); + } + else + { + resolve(response); + } + }) + ); } }); } @@ -345,17 +401,24 @@ export default class RoomClient try { - await this.sendRequest('change-display-name', { displayName }); + await this.sendRequest('changeDisplayName', { displayName }); store.dispatch(stateActions.setDisplayName(displayName)); - this.notify(`Your display name changed to ${displayName}.`); + store.dispatch(requestActions.notify( + { + text : `Your display name changed to ${displayName}.` + })); } catch (error) { logger.error('changeDisplayName() | failed: %o', error); - this.notify('An error occured while changing your display name.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'An error occured while changing your display name.' + })); // We need to refresh the component for it to render the previous // displayName again. @@ -369,7 +432,7 @@ export default class RoomClient try { - await this.sendRequest('change-profile-picture', { picture }); + await this.sendRequest('changeProfilePicture', { picture }); } catch (error) { @@ -386,13 +449,17 @@ export default class RoomClient store.dispatch( stateActions.addUserMessage(chatMessage.text)); - await this.sendRequest('chat-message', { chatMessage }); + await this.sendRequest('chatMessage', { chatMessage }); } catch (error) { logger.error('sendChatMessage() | failed: %o', error); - this.notify('An error occured while sending chat message.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to send chat message.' + })); } } @@ -402,7 +469,11 @@ export default class RoomClient { if (err) { - return this.notify('An error occurred while saving a file'); + return store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to save file.' + })); } saveAs(blob, file.name); @@ -466,72 +537,64 @@ export default class RoomClient async shareFiles(files) { - this.notify('Creating torrent'); + store.dispatch(requestActions.notify( + { + text : 'Starting file share.' + })); createTorrent(files, (err, torrent) => { if (err) { - return this.notify( - 'An error occured while uploading a file' - ); + return store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to upload file.' + })); } const existingTorrent = this._webTorrent.get(torrent); if (existingTorrent) { - const { displayName, picture } = store.getState().settings; - - const file = { - magnetUri : existingTorrent.magnetURI, - displayName, - picture - }; - - return this._sendFile(file); + return this._sendFile(existingTorrent.magnetURI); } this._webTorrent.seed(files, (newTorrent) => { - this.notify( - 'Torrent successfully created' - ); - - const { displayName, picture } = store.getState().settings; - const file = { - magnetUri : newTorrent.magnetURI, - displayName, - picture - }; - - store.dispatch(stateActions.addFile( + store.dispatch(requestActions.notify( { - magnetUri : file.magnetUri, - displayName : displayName, - picture : picture, - me : true + text : 'File successfully shared.' })); - this._sendFile(file); + store.dispatch(stateActions.addFile( + this._peerId, + newTorrent.magnetURI + )); + + this._sendFile(newTorrent.magnetURI); }); }); } // { file, name, picture } - async _sendFile(file) + async _sendFile(magnetUri) { - logger.debug('sendFile() [file: %o]', file); + logger.debug('sendFile() [magnetUri: %o]', magnetUri); try { - await this.sendRequest('send-file', { file }); + await this.sendRequest('sendFile', { magnetUri }); } catch (error) { logger.error('sendFile() | failed: %o', error); - this.notify('An error occurred while sharing file.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to share file.' + })); } } @@ -545,7 +608,7 @@ export default class RoomClient chatHistory, fileHistory, lastN - } = await this.sendRequest('server-history'); + } = await this.sendRequest('serverHistory'); if (chatHistory.length > 0) { @@ -566,7 +629,7 @@ export default class RoomClient logger.debug('Got lastN'); // Remove our self from list - const index = lastN.indexOf(this._peerName); + const index = lastN.indexOf(this._peerId); lastN.splice(index, 1); @@ -577,33 +640,37 @@ export default class RoomClient { logger.error('getServerHistory() | failed: %o', error); - this.notify('An error occured while getting server history.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to retrieve room history.' + })); } } - toggleMic() - { - logger.debug('toggleMic()'); - - if (this._micProducer.locallyPaused) - this.unmuteMic(); - else - this.muteMic(); - } - async muteMic() { logger.debug('muteMic()'); + this._micProducer.pause(); + try { - this._micProducer.pause(); + await this.sendRequest( + 'pauseProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerPaused(this._micProducer.id)); } catch (error) { logger.error('muteMic() | failed: %o', error); - this.notify('An error occured while accessing your microphone.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to access your microphone.' + })); } } @@ -611,20 +678,32 @@ export default class RoomClient { logger.debug('unmuteMic()'); - try + if (!this._micProducer) { - if (this._micProducer) - this._micProducer.resume(); - else if (this._room.canSend('audio')) - await this._setMicProducer(); - else - throw new Error('cannot send audio'); + this.enableMic(); } - catch (error) + else { - logger.error('unmuteMic() | failed: %o', error); + this._micProducer.resume(); - this.notify('An error occured while accessing your microphone.'); + try + { + await this.sendRequest( + 'resumeProducer', { producerId: this._micProducer.id }); + + store.dispatch( + stateActions.setProducerResumed(this._micProducer.id)); + } + catch (error) + { + logger.error('unmuteMic() | failed: %o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'An error occured while accessing your microphone.' + })); + } } } @@ -635,26 +714,17 @@ export default class RoomClient try { - for (const peer of this._room.peers) + for (const consumer of this._consumers.values()) { - if (spotlights.indexOf(peer.name) > -1) // Resume video for speaker + if (consumer.kind === 'video') { - for (const consumer of peer.consumers) + if (spotlights.indexOf(consumer.appData.peerId) > -1) { - if (consumer.kind !== 'video' || !consumer.supported) - continue; - - await consumer.resume(); + await this._resumeConsumer(consumer); } - } - else // Pause video for everybody else - { - for (const consumer of peer.consumers) + else { - if (consumer.kind !== 'video') - continue; - - await consumer.pause('not-speaker'); + await this._pauseConsumer(consumer); } } } @@ -665,125 +735,6 @@ export default class RoomClient } } - installExtension() - { - logger.debug('installExtension()'); - - return new Promise((resolve, reject) => - { - window.addEventListener('message', _onExtensionMessage, false); - // eslint-disable-next-line - chrome.webstore.install(null, _successfulInstall, _failedInstall); - function _onExtensionMessage({ data }) - { - if (data.type === 'ScreenShareInjected') - { - logger.debug('installExtension() | installation succeeded'); - - return resolve(); - } - } - - function _failedInstall(reason) - { - window.removeEventListener('message', _onExtensionMessage); - - return reject( - new Error('Failed to install extension: %s', reason)); - } - - function _successfulInstall() - { - logger.debug('installExtension() | installation accepted'); - } - }) - .then(() => - { - // This should be handled better - store.dispatch(stateActions.setScreenCapabilities( - { - canShareScreen : this._room.canSend('video'), - needExtension : false - })); - }) - .catch((error) => - { - logger.error('installExtension() | failed: %o', error); - }); - } - - async enableScreenSharing() - { - logger.debug('enableScreenSharing()'); - - store.dispatch(stateActions.setScreenShareInProgress(true)); - - try - { - await this._setScreenShareProducer(); - } - catch (error) - { - logger.error('enableScreenSharing() | failed: %o', error); - } - - store.dispatch(stateActions.setScreenShareInProgress(false)); - } - - async enableWebcam() - { - logger.debug('enableWebcam()'); - - store.dispatch(stateActions.setWebcamInProgress(true)); - - try - { - await this._setWebcamProducer(); - } - catch (error) - { - logger.error('enableWebcam() | failed: %o', error); - } - - store.dispatch(stateActions.setWebcamInProgress(false)); - } - - async disableScreenSharing() - { - logger.debug('disableScreenSharing()'); - - store.dispatch(stateActions.setScreenShareInProgress(true)); - - try - { - await this._screenSharingProducer.close(); - } - catch (error) - { - logger.error('disableScreenSharing() | failed: %o', error); - } - - store.dispatch(stateActions.setScreenShareInProgress(false)); - } - - async disableWebcam() - { - logger.debug('disableWebcam()'); - - store.dispatch(stateActions.setWebcamInProgress(true)); - - try - { - this._webcamProducer.close(); - } - catch (error) - { - logger.error('disableWebcam() | failed: %o', error); - } - - store.dispatch(stateActions.setWebcamInProgress(false)); - } - async changeAudioDevice(deviceId) { logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); @@ -801,6 +752,8 @@ export default class RoomClient logger.debug( 'changeAudioDevice() | new selected webcam [device:%o]', device); + + this._micProducer.track.stop(); logger.debug('changeAudioDevice() | calling getUserMedia()'); @@ -814,18 +767,24 @@ export default class RoomClient const track = stream.getAudioTracks()[0]; - const newTrack = await this._micProducer.replaceTrack(track); + await this._micProducer.replaceTrack({ track }); + + this._micProducer.volume = 0; const harkStream = new MediaStream(); - harkStream.addTrack(newTrack); + harkStream.addTrack(track); + 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 }); + + if (this._hark != null) + this._hark.stop(); + + this._hark = hark(harkStream, { play: false }); // eslint-disable-next-line no-unused-vars - this._micProducer.hark.on('volume_change', (dBs, threshold) => + this._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) @@ -836,18 +795,19 @@ export default class RoomClient if (volume === 1) volume = 0; + volume = Math.round(volume); - if (volume !== this._micProducer.volume) + + if (this._micProducer && volume !== this._micProducer.volume) { this._micProducer.volume = volume; - store.dispatch(stateActions.setPeerVolume(this._peerName, volume)); + + store.dispatch(stateActions.setPeerVolume(this._peerId, volume)); } }); - track.stop(); - store.dispatch( - stateActions.setProducerTrack(this._micProducer.id, newTrack)); + stateActions.setProducerTrack(this._micProducer.id, track)); store.dispatch(stateActions.setSelectedAudioDevice(deviceId)); @@ -878,6 +838,8 @@ export default class RoomClient if (!device) throw new Error('no webcam devices'); + this._webcamProducer.track.stop(); + logger.debug('changeVideoResolution() | calling getUserMedia()'); const stream = await navigator.mediaDevices.getUserMedia( @@ -891,12 +853,10 @@ export default class RoomClient const track = stream.getVideoTracks()[0]; - const newTrack = await this._webcamProducer.replaceTrack(track); - - track.stop(); + await this._webcamProducer.replaceTrack({ track }); store.dispatch( - stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + stateActions.setProducerTrack(this._webcamProducer.id, track)); store.dispatch(stateActions.setSelectedWebcamDevice(deviceId)); store.dispatch(stateActions.setVideoResolution(resolution)); @@ -931,6 +891,8 @@ export default class RoomClient 'changeWebcam() | new selected webcam [device:%o]', device); + this._webcamProducer.track.stop(); + logger.debug('changeWebcam() | calling getUserMedia()'); const stream = await navigator.mediaDevices.getUserMedia( @@ -944,12 +906,10 @@ export default class RoomClient const track = stream.getVideoTracks()[0]; - const newTrack = await this._webcamProducer.replaceTrack(track); - - track.stop(); + await this._webcamProducer.replaceTrack({ track }); store.dispatch( - stateActions.setProducerTrack(this._webcamProducer.id, newTrack)); + stateActions.setProducerTrack(this._webcamProducer.id, track)); store.dispatch(stateActions.setSelectedWebcamDevice(deviceId)); @@ -964,52 +924,48 @@ export default class RoomClient stateActions.setWebcamInProgress(false)); } - setSelectedPeer(peerName) + setSelectedPeer(peerId) { - logger.debug('setSelectedPeer() [peerName:"%s"]', peerName); + logger.debug('setSelectedPeer() [peerId:"%s"]', peerId); - this._spotlights.setPeerSpotlight(peerName); + this._spotlights.setPeerSpotlight(peerId); store.dispatch( - stateActions.setSelectedPeer(peerName)); + stateActions.setSelectedPeer(peerId)); } // type: mic/webcam/screen // mute: true/false - modifyPeerConsumer(peerName, type, mute) + async modifyPeerConsumer(peerId, type, mute) { logger.debug( - 'modifyPeerConsumer() [peerName:"%s", type:"%s"]', - peerName, + 'modifyPeerConsumer() [peerId:"%s", type:"%s"]', + peerId, type ); if (type === 'mic') store.dispatch( - stateActions.setPeerAudioInProgress(peerName, true)); + stateActions.setPeerAudioInProgress(peerId, true)); else if (type === 'webcam') store.dispatch( - stateActions.setPeerVideoInProgress(peerName, true)); + stateActions.setPeerVideoInProgress(peerId, true)); else if (type === 'screen') store.dispatch( - stateActions.setPeerScreenInProgress(peerName, true)); + stateActions.setPeerScreenInProgress(peerId, true)); try { - for (const peer of this._room.peers) + for (const consumer of this._consumers.values()) { - if (peer.name === peerName) + if (consumer.appData.peerId === peerId && consumer.appData.source === type) { - for (const consumer of peer.consumers) + if (mute) { - if (consumer.appData.source !== type || !consumer.supported) - continue; - - if (mute) - consumer.pause(`mute-${type}`); - else - consumer.resume(); + await this._pauseConsumer(consumer); } + else + await this._resumeConsumer(consumer); } } } @@ -1020,13 +976,57 @@ export default class RoomClient if (type === 'mic') store.dispatch( - stateActions.setPeerAudioInProgress(peerName, false)); + stateActions.setPeerAudioInProgress(peerId, false)); else if (type === 'webcam') store.dispatch( - stateActions.setPeerVideoInProgress(peerName, false)); + stateActions.setPeerVideoInProgress(peerId, false)); else if (type === 'screen') store.dispatch( - stateActions.setPeerScreenInProgress(peerName, false)); + stateActions.setPeerScreenInProgress(peerId, false)); + } + + async _pauseConsumer(consumer) + { + logger.debug('_pauseConsumer() [consumer: %o]', consumer); + + if (consumer.paused || consumer.closed) + return; + + try + { + await this.sendRequest('pauseConsumer', { consumerId: consumer.id }); + + consumer.pause(); + + store.dispatch( + stateActions.setConsumerPaused(consumer.id, 'local')); + } + catch (error) + { + logger.error('_pauseConsumer() | failed:%o', error); + } + } + + async _resumeConsumer(consumer) + { + logger.debug('_resumeConsumer() [consumer: %o]', consumer); + + if (!consumer.paused || consumer.closed) + return; + + try + { + await this.sendRequest('resumeConsumer', { consumerId: consumer.id }); + + consumer.resume(); + + store.dispatch( + stateActions.setConsumerResumed(consumer.id, 'local')); + } + catch (error) + { + logger.error('_resumeConsumer() | failed:%o', error); + } } async sendRaiseHandState(state) @@ -1038,7 +1038,7 @@ export default class RoomClient try { - await this.sendRequest('raisehand-message', { raiseHandState: state }); + await this.sendRequest('raiseHand', { raiseHandState: state }); store.dispatch( stateActions.setMyRaiseHandState(state)); @@ -1047,7 +1047,11 @@ export default class RoomClient { logger.error('sendRaiseHandState() | failed: %o', error); - this.notify(`An error occured while ${state ? 'raising' : 'lowering'} hand.`); + store.dispatch(requestActions.notify( + { + type : 'error', + text : `An error occured while ${state ? 'raising' : 'lowering'} hand.` + })); // We need to refresh the component for it to render changed state store.dispatch(stateActions.setMyRaiseHandState(!state)); @@ -1057,38 +1061,13 @@ export default class RoomClient stateActions.setMyRaiseHandStateInProgress(false)); } - async resumeAudio() - { - logger.debug('resumeAudio()'); - try - { - await this._audioContext.resume(); - - store.dispatch( - stateActions.setAudioSuspended({ audioSuspended: false })); - - } - catch (error) - { - store.dispatch( - stateActions.setAudioSuspended({ audioSuspended: true })); - logger.error('resumeAudioJoin() failed: %o', error); - } - } - - join() + async join({ joinVideo }) { this._signalingSocket = io(this._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 = this._roomId; - - this._spotlights = new Spotlights(this._maxSpotlights, this._room); + this._spotlights = new Spotlights(this._maxSpotlights, this._signalingSocket); + store.dispatch(stateActions.toggleJoined()); store.dispatch(stateActions.setRoomState('connecting')); this._signalingSocket.on('connect', () => @@ -1096,31 +1075,29 @@ export default class RoomClient logger.debug('signaling Peer "connect" event'); }); - this._signalingSocket.on('room-ready', () => - { - logger.debug('signaling Peer "room-ready" event'); - - this._joinRoom(); - }); - - this._signalingSocket.on('room-locked', () => - { - logger.debug('signaling Peer "room-locked" event'); - - store.dispatch(stateActions.setRoomLockedOut()); - }); - this._signalingSocket.on('disconnect', () => { logger.warn('signaling Peer "disconnect" event'); - this.notify('You are disconnected.'); + store.dispatch(requestActions.notify( + { + text : 'You are disconnected.' + })); - // Leave Room. - try { this._room.remoteClose({ cause: 'signaling disconnected' }); } - catch (error) {} + // Close mediasoup Transports. + if (this._sendTransport) + { + this._sendTransport.close(); + this._sendTransport = null; + } - store.dispatch(stateActions.setRoomState('connecting')); + if (this._recvTransport) + { + this._recvTransport.close(); + this._recvTransport = null; + } + + store.dispatch(stateActions.setRoomState('closed')); }); this._signalingSocket.on('close', () => @@ -1133,269 +1110,551 @@ export default class RoomClient this.close(); }); - this._signalingSocket.on('mediasoup-notification', (data) => + this._signalingSocket.on('request', async (request, cb) => { - const notification = data; + logger.debug( + 'socket "request" event [method:%s, data:%o]', + request.method, request.data); - this._room.receiveNotification(notification); - }); - - this._signalingSocket.on('lock-room', ({ peerName }) => - { - store.dispatch( - stateActions.setRoomLocked()); - - const peer = this._room.getPeerByName(peerName); - - if (peer) + switch (request.method) { - this.notify(`${peer.appData.displayName} locked the room.`); + case 'newConsumer': + { + const { + peerId, + producerId, + id, + kind, + rtpParameters, + type, + appData, + producerPaused + } = request.data; + + let codecOptions; + + if (kind === 'audio') + { + codecOptions = + { + opusStereo : 1 + }; + } + + const consumer = await this._recvTransport.consume( + { + id, + producerId, + kind, + rtpParameters, + codecOptions, + appData : { ...appData, peerId } // Trick. + }); + + // Store in the map. + this._consumers.set(consumer.id, consumer); + + consumer.on('transportclose', () => + { + this._consumers.delete(consumer.id); + }); + + const { spatialLayers, temporalLayers } = + mediasoupClient.parseScalabilityMode( + consumer.rtpParameters.encodings[0].scalabilityMode); + + store.dispatch(stateActions.addConsumer( + { + id : consumer.id, + peerId : peerId, + kind : kind, + type : type, + locallyPaused : false, + remotelyPaused : producerPaused, + rtpParameters : consumer.rtpParameters, + source : consumer.appData.source, + spatialLayers : spatialLayers, + temporalLayers : temporalLayers, + preferredSpatialLayer : spatialLayers - 1, + preferredTemporalLayer : temporalLayers - 1, + codec : consumer.rtpParameters.codecs[0].mimeType.split('/')[1], + track : consumer.track + }, + peerId)); + + // We are ready. Answer the request so the server will + // resume this Consumer (which was paused for now). + cb(null); + + if (kind === 'audio') + { + consumer.volume = 0; + + const stream = new MediaStream(); + + stream.addTrack(consumer.track); + + if (!stream.getAudioTracks()[0]) + throw new Error('request.newConsumer | given stream has no audio track'); + + consumer.hark = hark(stream, { play: false }); + + // eslint-disable-next-line no-unused-vars + consumer.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; + + volume = Math.round(volume); + + if (consumer && volume !== consumer.volume) + { + consumer.volume = volume; + + store.dispatch(stateActions.setPeerVolume(peerId, volume)); + } + }); + } + + break; + } + + default: + { + logger.error('unknown request.method "%s"', request.method); + + cb(500, `unknown request.method "${request.method}"`); + } } }); - this._signalingSocket.on('unlock-room', ({ peerName }) => + this._signalingSocket.on('notification', async (notification) => { - store.dispatch( - stateActions.setRoomUnLocked()); + logger.debug( + 'socket "notification" event [method:%s, data:%o]', + notification.method, notification.data); - const peer = this._room.getPeerByName(peerName); - - if (peer) + switch (notification.method) { - this.notify(`${peer.appData.displayName} unlocked the room.`); - } - }); + case 'roomReady': + { + await this._joinRoom({ joinVideo }); - this._signalingSocket.on('active-speaker', ({ peerName }) => - { - store.dispatch( - stateActions.setRoomActiveSpeaker(peerName)); + break; + } - if (peerName && peerName !== this._peerName) - this._spotlights.handleActiveSpeaker(peerName); - }); + case 'roomLocked': + { + store.dispatch(stateActions.setRoomLockedOut()); - this._signalingSocket.on('display-name-changed', ({ peerName, displayName: name }) => - { - // 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 'lockRoom': + { + store.dispatch( + stateActions.setRoomLocked()); - return; - } + store.dispatch(requestActions.notify( + { + text : 'Room is now locked.' + })); - const oldDisplayName = peer.appData.name; + break; + } - peer.appData.displayName = name; + case 'unlockRoom': + { + store.dispatch( + stateActions.setRoomUnLocked()); + + store.dispatch(requestActions.notify( + { + text : 'Room is now unlocked.' + })); - store.dispatch( - stateActions.setPeerDisplayName(name, peerName)); + break; + } - this.notify(`${oldDisplayName} changed their display name to ${name}.`); - }); + case 'activeSpeaker': + { + const { peerId } = notification.data; - this._signalingSocket.on('profile-picture-changed', ({ peerName, picture }) => - { - store.dispatch(stateActions.setPeerPicture(peerName, picture)); - }); + store.dispatch( + stateActions.setRoomActiveSpeaker(peerId)); + + if (peerId && peerId !== this._peerId) + this._spotlights.handleActiveSpeaker(peerId); - // This means: server wants to change MY user information - this._signalingSocket.on('auth', (data) => - { - logger.debug('got auth event from server', data); + break; + } - this.changeDisplayName(data.name); + case 'changeDisplayName': + { + const { peerId, displayName, oldDisplayName } = notification.data; - this.changeProfilePicture(data.picture); - store.dispatch(stateActions.setPicture(data.picture)); - store.dispatch(stateActions.loggedIn()); + store.dispatch( + stateActions.setPeerDisplayName(displayName, peerId)); - this.notify('You are logged in.'); + store.dispatch(requestActions.notify( + { + text : `${oldDisplayName} is now ${displayName}` + })); - this.closeLoginWindow(); - }); + break; + } - this._signalingSocket.on('raisehand-message', (data) => - { - const { peerName, raiseHandState } = data; + case 'changeProfilePicture': + { + const { peerId, picture } = notification.data; - logger.debug('Got raiseHandState from "%s"', peerName); + store.dispatch(stateActions.setPeerPicture(peerId, picture)); - // 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 'auth': + { + const { displayName, picture } = notification.data; - return; - } + this.changeDisplayName(displayName); - this.notify(`${peer.appData.displayName} ${raiseHandState ? 'raised' : 'lowered'} their hand.`); + this.changeProfilePicture(picture); + store.dispatch(stateActions.setPicture(picture)); + store.dispatch(stateActions.loggedIn()); - store.dispatch( - stateActions.setPeerRaiseHandState(peerName, raiseHandState)); - }); + store.dispatch(requestActions.notify( + { + text : 'You are logged in.' + })); - this._signalingSocket.on('chat-message-receive', (data) => - { - const { peerName, chatMessage } = data; + this.closeLoginWindow(); - logger.debug('Got chat from "%s"', peerName); + break; + } - store.dispatch( - stateActions.addResponseMessage({ ...chatMessage, peerName })); + case 'chatMessage': + { + const { peerId, chatMessage } = notification.data; - if (!store.getState().toolarea.toolAreaOpen || - (store.getState().toolarea.toolAreaOpen && - store.getState().toolarea.currentToolTab !== 'chat')) // Make sound - { - this._soundNotification(); - } - }); + store.dispatch( + stateActions.addResponseMessage({ ...chatMessage, peerId })); - this._signalingSocket.on('file-receive', (data) => - { - const { peerName, file } = data; + if ( + !store.getState().toolarea.toolAreaOpen || + (store.getState().toolarea.toolAreaOpen && + store.getState().toolarea.currentToolTab !== 'chat') + ) // Make sound + { + this._soundNotification(); + } - // 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 'sendFile': + { + const { peerId, magnetUri } = notification.data; - return; - } + store.dispatch(stateActions.addFile(peerId, magnetUri)); - store.dispatch(stateActions.addFile(file)); + store.dispatch(requestActions.notify( + { + text : 'New file available.' + })); - this.notify(`${peer.appData.displayName} shared a file.`); + if ( + !store.getState().toolarea.toolAreaOpen || + (store.getState().toolarea.toolAreaOpen && + store.getState().toolarea.currentToolTab !== 'files') + ) // Make sound + { + this._soundNotification(); + } - if (!store.getState().toolarea.toolAreaOpen || - (store.getState().toolarea.toolAreaOpen && - store.getState().toolarea.currentToolTab !== 'files')) // Make sound - { - this._soundNotification(); + break; + } + + case 'producerScore': + { + const { producerId, score } = notification.data; + + store.dispatch( + stateActions.setProducerScore(producerId, score)); + + break; + } + + case 'newPeer': + { + const { id, displayName, picture, device } = notification.data; + + store.dispatch( + stateActions.addPeer({ id, displayName, picture, device, consumers: [] })); + + store.dispatch(requestActions.notify( + { + text : `${displayName} joined the room.` + })); + + break; + } + + case 'peerClosed': + { + const { peerId } = notification.data; + + store.dispatch( + stateActions.removePeer(peerId)); + + break; + } + + case 'consumerClosed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + consumer.close(); + + if (consumer.hark != null) + consumer.hark.stop(); + + this._consumers.delete(consumerId); + + const { peerId } = consumer.appData; + + store.dispatch( + stateActions.removeConsumer(consumerId, peerId)); + + break; + } + + case 'consumerPaused': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch( + stateActions.setConsumerPaused(consumerId, 'remote')); + + break; + } + + case 'consumerResumed': + { + const { consumerId } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch( + stateActions.setConsumerResumed(consumerId, 'remote')); + + break; + } + + case 'consumerLayersChanged': + { + const { consumerId, spatialLayer, temporalLayer } = notification.data; + const consumer = this._consumers.get(consumerId); + + if (!consumer) + break; + + store.dispatch(stateActions.setConsumerCurrentLayers( + consumerId, spatialLayer, temporalLayer)); + + break; + } + + case 'consumerScore': + { + const { consumerId, score } = notification.data; + + store.dispatch( + stateActions.setConsumerScore(consumerId, score)); + + break; + } + + default: + { + logger.error( + 'unknown notification.method "%s"', notification.method); + } } }); } - async _joinRoom() + async _joinRoom({ joinVideo }) { logger.debug('_joinRoom()'); - // 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(); - - this._room.on('close', (originator, appData) => - { - if (originator === 'remote') - { - logger.warn('mediasoup Peer/Room remotely closed [appData:%o]', appData); - - store.dispatch(stateActions.setRoomState('closed')); - - return; - } - }); - - this._room.on('request', (request, callback, errback) => - { - logger.debug( - 'sending mediasoup request [method:%s]:%o', request.method, request); - - this.sendRequest('mediasoup-request', request) - .then(callback) - .catch(errback); - }); - - this._room.on('notify', (notification) => - { - logger.debug( - 'sending mediasoup notification [method:%s]:%o', - notification.method, notification); - - this.sendRequest('mediasoup-notification', notification) - .catch((error) => - { - logger.warn('could not send mediasoup notification:%o', error); - }); - }); - - this._room.on('newpeer', (peer) => - { - logger.debug( - 'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer); - - this._soundNotification(); - - if (this._doneJoining) - { - this._handlePeer(peer); - } - }); + const { + displayName, + picture + } = store.getState().settings; try { - const { displayName } = store.getState().settings; + this._mediasoupDevice = new mediasoupClient.Device(); - await this._room.join( - this._peerName, - { - displayName : displayName, - device : this._device - } - ); + const routerRtpCapabilities = + await this.sendRequest('getRouterRtpCapabilities'); - store.dispatch( - stateActions.setFileSharingSupported(this._torrentSupport)); + await this._mediasoupDevice.load({ routerRtpCapabilities }); - this._sendTransport = - this._room.createTransport('send', { media: 'SEND_MIC_WEBCAM' }); - - this._sendTransport.on('close', (originator) => + if (this._produce) { - logger.debug( - 'Transport "close" event [originator:%s]', originator); - }); + const transportInfo = await this.sendRequest( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : true, + consuming : false + }); - // Create Transport for receiving. - this._recvTransport = - this._room.createTransport('recv', { media: 'RECV' }); + const { + id, + iceParameters, + iceCandidates, + dtlsParameters + } = transportInfo; - this._recvTransport.on('close', (originator) => + this._sendTransport = this._mediasoupDevice.createSendTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters + }); + + this._sendTransport.on( + 'connect', ({ dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this.sendRequest( + 'connectWebRtcTransport', + { + transportId : this._sendTransport.id, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + + this._sendTransport.on( + 'produce', ({ kind, rtpParameters, appData }, callback, errback) => + { + this.sendRequest( + 'produce', + { + transportId : this._sendTransport.id, + kind, + rtpParameters, + appData + }) + .then(callback) + .catch(errback); + }); + } + + if (this._consume) { - logger.debug( - 'receiving Transport "close" event [originator:%s]', originator); - }); + const transportInfo = await this.sendRequest( + 'createWebRtcTransport', + { + forceTcp : this._forceTcp, + producing : false, + consuming : true + }); + + const { + id, + iceParameters, + iceCandidates, + dtlsParameters + } = transportInfo; + + this._recvTransport = this._mediasoupDevice.createRecvTransport( + { + id, + iceParameters, + iceCandidates, + dtlsParameters + }); + + this._recvTransport.on( + 'connect', ({ dtlsParameters }, callback, errback) => // eslint-disable-line no-shadow + { + this.sendRequest( + 'connectWebRtcTransport', + { + transportId : this._recvTransport.id, + dtlsParameters + }) + .then(callback) + .catch(errback); + }); + } // Set our media capabilities. store.dispatch(stateActions.setMediaCapabilities( { - canSendMic : this._room.canSend('audio'), - canSendWebcam : this._room.canSend('video') - })); - store.dispatch(stateActions.setScreenCapabilities( - { - canShareScreen : this._room.canSend('video') && + canSendMic : this._mediasoupDevice.canProduce('audio'), + canSendWebcam : this._mediasoupDevice.canProduce('video'), + canShareScreen : this._mediasoupDevice.canProduce('video') && this._screenSharing.isScreenShareAvailable(), - needExtension : this._screenSharing.needExtension() + canShareFiles : this._torrentSupport })); + const { peers } = await this.sendRequest( + 'join', + { + displayName : displayName, + picture : picture, + device : this._device, + rtpCapabilities : this._consume + ? this._mediasoupDevice.rtpCapabilities + : undefined + }); + + for (const peer of peers) + { + store.dispatch( + stateActions.addPeer({ ...peer, consumers: [] })); + } + + this._spotlights.addPeers(peers); + + this._spotlights.on('spotlights-updated', (spotlights) => + { + store.dispatch(stateActions.setSpotlights(spotlights)); + this.updateSpotlights(spotlights); + }); + // Don't produce if explicitely requested to not to do it. if (this._produce) { - if (this._room.canSend('audio')) - await this._setMicProducer(); + if (this._mediasoupDevice.canProduce('audio')) + this.enableMic(); - if (this._room.canSend('video')) - await this.enableWebcam(); + if (joinVideo && this._mediasoupDevice.canProduce('video')) + this.enableWebcam(); } store.dispatch(stateActions.setRoomState('connected')); @@ -1405,22 +1664,10 @@ export default class RoomClient this.getServerHistory(); - this.notify('You have joined the room.'); - - this._spotlights.on('spotlights-updated', (spotlights) => - { - store.dispatch(stateActions.setSpotlights(spotlights)); - this.updateSpotlights(spotlights); - }); - - const peers = this._room.peers; - - for (const peer of peers) - { - this._handlePeer(peer, { notify: false }); - } - - this._doneJoining = true; + store.dispatch(requestActions.notify( + { + text : 'You have joined the room.' + })); this._spotlights.start(); } @@ -1428,7 +1675,11 @@ export default class RoomClient { logger.error('_joinRoom() failed:%o', error); - this.notify('An error occured while joining the room.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to join the room.' + })); this.close(); } @@ -1440,14 +1691,24 @@ export default class RoomClient try { - await this.sendRequest('lock-room'); + await this.sendRequest('lockRoom'); store.dispatch( stateActions.setRoomLocked()); - this.notify('You locked the room.'); + + store.dispatch(requestActions.notify( + { + text : 'You locked the room.' + })); } catch (error) { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to lock the room.' + })); + logger.error('lockRoom() | failed: %o', error); } } @@ -1458,27 +1719,44 @@ export default class RoomClient try { - await this.sendRequest('unlock-room'); + await this.sendRequest('unlockRoom'); store.dispatch( stateActions.setRoomUnLocked()); - this.notify('You unlocked the room.'); + + store.dispatch(requestActions.notify( + { + text : 'You unlocked the room.' + })); } catch (error) { + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Unable to unlock the room.' + })); + logger.error('unlockRoom() | failed: %o', error); } } - async _setMicProducer() + async enableMic() { - if (!this._room.canSend('audio')) - throw new Error('cannot send audio'); - if (this._micProducer) - throw new Error('mic Producer already exists'); + return; - let producer; + if (!this._mediasoupDevice.canProduce('audio')) + { + logger.error('enableMic() | cannot produce audio'); + + return; + } + + let track; + + store.dispatch( + stateActions.setAudioInProgress(true)); try { @@ -1490,10 +1768,10 @@ export default class RoomClient throw new Error('no audio devices'); logger.debug( - '_setMicProducer() | new selected audio device [device:%o]', + 'enableMic() | new selected audio device [device:%o]', device); - logger.debug('_setMicProducer() | calling getUserMedia()'); + logger.debug('enableMic() | calling getUserMedia()'); const stream = await navigator.mediaDevices.getUserMedia( { @@ -1503,76 +1781,67 @@ export default class RoomClient } ); - const track = stream.getAudioTracks()[0]; + track = stream.getAudioTracks()[0]; - producer = this._room.createProducer(track, null, { source: 'mic' }); - - // No need to keep original track. - track.stop(); - - // Send it. - await producer.send(this._sendTransport); - - this._micProducer = producer; + this._micProducer = await this._sendTransport.produce( + { + track, + codecOptions : + { + opusStereo : 1, + opusDtx : 1 + }, + appData : + { source: 'mic' } + }); store.dispatch(stateActions.addProducer( { - id : producer.id, - source : 'mic', - locallyPaused : producer.locallyPaused, - remotelyPaused : producer.remotelyPaused, - track : producer.track, - codec : producer.rtpParameters.codecs[0].name + id : this._micProducer.id, + source : 'mic', + paused : this._micProducer.paused, + track : this._micProducer.track, + rtpParameters : this._micProducer.rtpParameters, + codec : this._micProducer.rtpParameters.codecs[0].mimeType.split('/')[1] })); store.dispatch(stateActions.setSelectedAudioDevice(deviceId)); await this._updateAudioDevices(); - producer.on('close', (originator) => + this._micProducer.on('transportclose', () => { - logger.debug( - 'mic Producer "close" event [originator:%s]', originator); - this._micProducer = null; - store.dispatch(stateActions.removeProducer(producer.id)); }); - producer.on('pause', (originator) => + this._micProducer.on('trackended', () => { - logger.debug( - 'mic Producer "pause" event [originator:%s]', originator); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Microphone disconnected!' + })); - store.dispatch(stateActions.setProducerPaused(producer.id, originator)); + this.disableMic() + .catch(() => {}); }); - producer.on('resume', (originator) => - { - logger.debug( - 'mic Producer "resume" event [originator:%s]', originator); - - store.dispatch(stateActions.setProducerResumed(producer.id, originator)); - }); - - producer.on('handled', () => - { - logger.debug('mic Producer "handled" event'); - }); - - producer.on('unhandled', () => - { - logger.debug('mic Producer "unhandled" event'); - }); + this._micProducer.volume = 0; const harkStream = new MediaStream(); - harkStream.addTrack(producer.track); + harkStream.addTrack(track); + if (!harkStream.getAudioTracks()[0]) - throw new Error('_setMicProducer(): given stream has no audio track'); - producer.hark = hark(harkStream, { play: false }); + throw new Error('enableMic(): given stream has no audio track'); + + if (this._hark != null) + this._hark.stop(); + + this._hark = hark(harkStream, { play: false }); // eslint-disable-next-line no-unused-vars - producer.hark.on('volume_change', (dBs, threshold) => + this._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) @@ -1583,51 +1852,93 @@ export default class RoomClient if (volume === 1) volume = 0; + volume = Math.round(volume); - if (volume !== producer.volume) + + if (this._micProducer && volume !== this._micProducer.volume) { - producer.volume = volume; - store.dispatch(stateActions.setPeerVolume(this._peerName, volume)); + this._micProducer.volume = volume; + + store.dispatch(stateActions.setPeerVolume(this._peerId, volume)); } }); - - if (this._audioContext) - { - // We need to provoke user interaction to get permission from browser to start audio - if (this._audioContext.state === 'suspended') - { - this.resumeAudio(); - } - } } catch (error) { - logger.error('_setMicProducer() failed:%o', error); + logger.error('enableMic() failed:%o', error); - this.notify('An error occured while accessing your microphone.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'An error occured while accessing your microphone.' + })); - if (producer) - producer.close(); + if (track) + track.stop(); } + + store.dispatch( + stateActions.setAudioInProgress(false)); } - async _setScreenShareProducer() + async disableMic() { - if (!this._room.canSend('video')) - throw new Error('cannot send screen'); + logger.debug('disableMic()'); - let producer; + if (!this._micProducer) + return; + + store.dispatch(stateActions.setAudioInProgress(true)); + + this._micProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._micProducer.id)); try { - const available = this._screenSharing.isScreenShareAvailable() && - !this._screenSharing.needExtension(); + await this.sendRequest( + 'closeProducer', { producerId: this._micProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side mic Producer: ${error}` + })); + } + + this._micProducer = null; + + store.dispatch(stateActions.setAudioInProgress(false)); + } + + async enableScreenSharing() + { + if (this._screenSharingProducer) + return; + + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableScreenSharing() | cannot produce video'); + + return; + } + + let track; + + store.dispatch(stateActions.setScreenShareInProgress(true)); + + try + { + const available = this._screenSharing.isScreenShareAvailable(); if (!available) throw new Error('screen sharing not available'); - logger.debug('_setScreenShareProducer() | calling getUserMedia()'); + logger.debug('enableScreenSharing() | calling getUserMedia()'); const stream = await this._screenSharing.start({ width : 1280, @@ -1635,105 +1946,132 @@ export default class RoomClient frameRate : 3 }); - const track = stream.getVideoTracks()[0]; + 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; + if (this._useSimulcast) + { + this._screenSharingProducer = await this._sendTransport.produce( + { + track, + encodings : VIDEO_ENCODINGS, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'screen' + } + }); + } + else + { + this._screenSharingProducer = await this._sendTransport.produce({ + track, + appData : + { + source : 'screen' + } + }); + } store.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 + id : this._screenSharingProducer.id, + deviceLabel : 'screen', + source : 'screen', + paused : this._screenSharingProducer.paused, + track : this._screenSharingProducer.track, + rtpParameters : this._screenSharingProducer.rtpParameters, + codec : this._screenSharingProducer.rtpParameters.codecs[0].mimeType.split('/')[1] })); - producer.on('close', (originator) => + this._screenSharingProducer.on('transportclose', () => { - logger.debug( - 'webcam Producer "close" event [originator:%s]', originator); - this._screenSharingProducer = null; - store.dispatch(stateActions.removeProducer(producer.id)); }); - producer.on('trackended', (originator) => + this._screenSharingProducer.on('trackended', () => { - logger.debug( - 'webcam Producer "trackended" event [originator:%s]', originator); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Screen sharing disconnected!' + })); - this.disableScreenSharing(); + this.disableScreenSharing() + .catch(() => {}); }); - producer.on('pause', (originator) => - { - logger.debug( - 'webcam Producer "pause" event [originator:%s]', originator); - - store.dispatch(stateActions.setProducerPaused(producer.id, originator)); - }); - - producer.on('resume', (originator) => - { - logger.debug( - 'webcam Producer "resume" event [originator:%s]', originator); - - store.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'); + logger.debug('enableScreenSharing() succeeded'); } catch (error) { - logger.error('_setScreenShareProducer() failed:%o', error); + logger.error('enableScreenSharing() 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.'); - } + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'An error occured while accessing your camera.' + })); - if (producer) - producer.close(); - - throw error; + if (track) + track.stop(); } + + store.dispatch(stateActions.setScreenShareInProgress(false)); } - async _setWebcamProducer() + async disableScreenSharing() + { + logger.debug('disableScreenSharing()'); + + if (!this._screenSharingProducer) + return; + + store.dispatch(stateActions.setScreenShareInProgress(true)); + + this._screenSharingProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._screenSharingProducer.id)); + + try + { + await this.sendRequest( + 'closeProducer', { producerId: this._screenSharingProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side screen Producer: ${error}` + })); + } + + this._screenSharingProducer = null; + + store.dispatch(stateActions.setScreenShareInProgress(false)); + } + + async enableWebcam() { - if (!this._room.canSend('video')) - throw new Error('cannot send video'); if (this._webcamProducer) - throw new Error('webcam Producer already exists'); + return; - let producer; + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableWebcam() | cannot produce video'); + + return; + } + + let track; + + store.dispatch( + stateActions.setWebcamInProgress(true)); try { @@ -1760,66 +2098,65 @@ export default class RoomClient } }); - const track = stream.getVideoTracks()[0]; + 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; + if (this._useSimulcast) + { + this._webcamProducer = await this._sendTransport.produce( + { + track, + encodings : VIDEO_ENCODINGS, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'webcam' + } + }); + } + else + { + this._webcamProducer = await this._sendTransport.produce({ + track, + appData : + { + source : 'webcam' + } + }); + } store.dispatch(stateActions.addProducer( { - id : producer.id, - source : 'webcam', - locallyPaused : producer.locallyPaused, - remotelyPaused : producer.remotelyPaused, - track : producer.track, - codec : producer.rtpParameters.codecs[0].name + id : this._webcamProducer.id, + deviceLabel : device.label, + source : 'webcam', + paused : this._webcamProducer.paused, + track : this._webcamProducer.track, + rtpParameters : this._webcamProducer.rtpParameters, + codec : this._webcamProducer.rtpParameters.codecs[0].mimeType.split('/')[1] })); store.dispatch(stateActions.setSelectedWebcamDevice(deviceId)); await this._updateWebcams(); - producer.on('close', (originator) => + this._webcamProducer.on('transportclose', () => { - logger.debug( - 'webcam Producer "close" event [originator:%s]', originator); - this._webcamProducer = null; - store.dispatch(stateActions.removeProducer(producer.id)); }); - producer.on('pause', (originator) => + this._webcamProducer.on('trackended', () => { - logger.debug( - 'webcam Producer "pause" event [originator:%s]', originator); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'Webcam disconnected!' + })); - store.dispatch(stateActions.setProducerPaused(producer.id, originator)); - }); - - producer.on('resume', (originator) => - { - logger.debug( - 'webcam Producer "resume" event [originator:%s]', originator); - - store.dispatch(stateActions.setProducerResumed(producer.id, originator)); - }); - - producer.on('handled', () => - { - logger.debug('webcam Producer "handled" event'); - }); - - producer.on('unhandled', () => - { - logger.debug('webcam Producer "unhandled" event'); + this.disableWebcam() + .catch(() => {}); }); logger.debug('_setWebcamProducer() succeeded'); @@ -1828,13 +2165,51 @@ export default class RoomClient { logger.error('_setWebcamProducer() failed:%o', error); - this.notify('An error occured while accessing your camera.'); + store.dispatch(requestActions.notify( + { + type : 'error', + text : 'An error occured while accessing your camera.' + })); - if (producer) - producer.close(); - - throw error; + if (track) + track.stop(); } + + store.dispatch( + stateActions.setWebcamInProgress(false)); + } + + async disableWebcam() + { + logger.debug('disableWebcam()'); + + if (!this._webcamProducer) + return; + + store.dispatch(stateActions.setWebcamInProgress(true)); + + this._webcamProducer.close(); + + store.dispatch( + stateActions.removeProducer(this._webcamProducer.id)); + + try + { + await this.sendRequest( + 'closeProducer', { producerId: this._webcamProducer.id }); + } + catch (error) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : `Error closing server-side webcam Producer: ${error}` + })); + } + + this._webcamProducer = null; + + store.dispatch(stateActions.setWebcamInProgress(false)); } async _updateAudioDevices() @@ -1950,167 +2325,4 @@ export default class RoomClient logger.error('_getWebcamDeviceId() failed:%o', error); } } - - _handlePeer(peer, { notify = true } = {}) - { - const displayName = peer.appData.displayName; - - store.dispatch(stateActions.addPeer( - { - name : peer.name, - displayName : displayName, - device : peer.appData.device, - raiseHandState : peer.appData.raiseHandState, - consumers : [] - })); - - if (notify) - { - this.notify(`${displayName} joined the room.`); - } - - for (const consumer of peer.consumers) - { - this._handleConsumer(consumer); - } - - peer.on('close', (originator) => - { - logger.debug( - 'peer "close" event [name:"%s", originator:%s]', - peer.name, originator); - - store.dispatch(stateActions.removePeer(peer.name)); - - if (this._room.joined) - { - this.notify(`${displayName} left the room.`); - } - }); - - peer.on('newconsumer', (consumer) => - { - logger.debug( - 'peer "newconsumer" event [name:"%s", id:%s, consumer:%o]', - peer.name, consumer.id, consumer); - - this._handleConsumer(consumer); - }); - } - - _handleConsumer(consumer) - { - const codec = consumer.rtpParameters.codecs[0]; - - store.dispatch(stateActions.addConsumer( - { - id : consumer.id, - peerName : consumer.peer.name, - source : consumer.appData.source, - supported : consumer.supported, - locallyPaused : consumer.locallyPaused, - remotelyPaused : consumer.remotelyPaused, - track : null, - codec : codec ? codec.name : null - }, - consumer.peer.name) - ); - - consumer.on('close', (originator) => - { - logger.debug( - 'consumer "close" event [id:%s, originator:%s, consumer:%o]', - consumer.id, originator, consumer); - - store.dispatch(stateActions.removeConsumer( - consumer.id, consumer.peer.name)); - }); - - consumer.on('handled', (originator) => - { - logger.debug( - 'consumer "handled" event [id:%s, originator:%s, consumer:%o]', - consumer.id, originator, consumer); - if (consumer.kind === 'audio') - { - const stream = new MediaStream(); - - stream.addTrack(consumer.track); - if (!stream.getAudioTracks()[0]) - throw new Error('consumer.on("handled" | given stream has no audio track'); - - consumer.hark = hark(stream, { play: false }); - - // eslint-disable-next-line no-unused-vars - consumer.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; - volume = Math.round(volume); - if (volume !== consumer.volume) - { - consumer.volume = volume; - store.dispatch(stateActions.setPeerVolume(consumer.peer.name, volume)); - } - }); - } - }); - - consumer.on('pause', (originator) => - { - logger.debug( - 'consumer "pause" event [id:%s, originator:%s, consumer:%o]', - consumer.id, originator, consumer); - - store.dispatch(stateActions.setConsumerPaused(consumer.id, originator)); - }); - - consumer.on('resume', (originator) => - { - logger.debug( - 'consumer "resume" event [id:%s, originator:%s, consumer:%o]', - consumer.id, originator, consumer); - - store.dispatch(stateActions.setConsumerResumed(consumer.id, originator)); - }); - - consumer.on('effectiveprofilechange', (profile) => - { - logger.debug( - 'consumer "effectiveprofilechange" event [id:%s, consumer:%o, profile:%s]', - consumer.id, consumer, profile); - - store.dispatch(stateActions.setConsumerEffectiveProfile(consumer.id, profile)); - }); - - // Receive the consumer (if we can). - if (consumer.supported) - { - 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) => - { - store.dispatch(stateActions.setConsumerTrack(consumer.id, track)); - }) - .catch((error) => - { - logger.error( - 'unexpected error while receiving a new Consumer:%o', error); - }); - } - } } diff --git a/app/src/ScreenShare.js b/app/src/ScreenShare.js index 9df56b4..c9336d1 100644 --- a/app/src/ScreenShare.js +++ b/app/src/ScreenShare.js @@ -1,113 +1,4 @@ -class ChromeScreenShare -{ - constructor() - { - this._stream = null; - } - - start(options = { }) - { - const state = this; - - return new Promise((resolve, reject) => - { - window.addEventListener('message', _onExtensionMessage, false); - window.postMessage({ type: 'getStreamId' }, '*'); - - function _onExtensionMessage({ data }) - { - if (data.type !== 'gotStreamId') - { - return; - } - - const constraints = state._toConstraints(options, data.streamId); - - navigator.mediaDevices.getUserMedia(constraints) - .then((stream) => - { - window.removeEventListener('message', _onExtensionMessage); - - state._stream = stream; - resolve(stream); - }) - .catch((err) => - { - window.removeEventListener('message', _onExtensionMessage); - - reject(err); - }); - } - }); - } - - stop() - { - if (this._stream instanceof MediaStream === false) - { - return; - } - - this._stream.getTracks().forEach((track) => track.stop()); - this._stream = null; - } - - isScreenShareAvailable() - { - if ('__multipartyMeetingScreenShareExtensionAvailable__' in window) - { - return true; - } - - return false; - } - - needExtension() - { - if ('__multipartyMeetingScreenShareExtensionAvailable__' in window) - { - return false; - } - - return true; - } - - _toConstraints(options, streamId) - { - const constraints = { - video : { - mandatory : { - chromeMediaSource : 'desktop', - chromeMediaSourceId : streamId - }, - optional : [ { - googTemporalLayeredScreencast : true - } ] - }, - audio : false - }; - - if (isFinite(options.width)) - { - constraints.video.mandatory.maxWidth = options.width; - constraints.video.mandatory.minWidth = options.width; - } - if (isFinite(options.height)) - { - constraints.video.mandatory.maxHeight = options.height; - constraints.video.mandatory.minHeight = options.height; - } - if (isFinite(options.frameRate)) - { - constraints.video.mandatory.maxFrameRate = options.frameRate; - constraints.video.mandatory.minFrameRate = options.frameRate; - } - - return constraints; - } -} - -class Chrome72ScreenShare +class DisplayMediaScreenShare { constructor() { @@ -143,11 +34,6 @@ class Chrome72ScreenShare return true; } - needExtension() - { - return false; - } - _toConstraints() { const constraints = { @@ -194,11 +80,6 @@ class FirefoxScreenShare return true; } - needExtension() - { - return false; - } - _toConstraints(options) { const constraints = { @@ -238,119 +119,12 @@ class FirefoxScreenShare } } -class Firefox66ScreenShare -{ - constructor() - { - this._stream = null; - } - - start(options = {}) - { - const constraints = this._toConstraints(options); - - return navigator.mediaDevices.getDisplayMedia(constraints) - .then((stream) => - { - this._stream = stream; - - return Promise.resolve(stream); - }); - } - - stop() - { - if (this._stream instanceof MediaStream === false) - { - return; - } - - this._stream.getTracks().forEach((track) => track.stop()); - this._stream = null; - } - - isScreenShareAvailable() - { - return true; - } - - needExtension() - { - return false; - } - - _toConstraints() - { - const constraints = { - video : true - }; - - return constraints; - } -} - -class EdgeScreenShare -{ - constructor() - { - this._stream = null; - } - - start(options = {}) - { - const constraints = this._toConstraints(options); - - return navigator.getDisplayMedia(constraints) - .then((stream) => - { - this._stream = stream; - - return Promise.resolve(stream); - }); - } - - stop() - { - if (this._stream instanceof MediaStream === false) - { - return; - } - - this._stream.getTracks().forEach((track) => track.stop()); - this._stream = null; - } - - isScreenShareAvailable() - { - return true; - } - - needExtension() - { - return false; - } - - _toConstraints() - { - const constraints = { - video : true - }; - - return constraints; - } -} - class DefaultScreenShare { isScreenShareAvailable() { return false; } - - needExtension() - { - return false; - } } export default class ScreenShare @@ -364,18 +138,15 @@ export default class ScreenShare if (device.version < 66.0) return new FirefoxScreenShare(); else - return new Firefox66ScreenShare(); + return new DisplayMediaScreenShare(); } case 'chrome': { - if (device.version < 72.0) - return new ChromeScreenShare(); - else - return new Chrome72ScreenShare(); + return new DisplayMediaScreenShare(); } case 'msedge': { - return new EdgeScreenShare(); + return new DisplayMediaScreenShare(); } default: { diff --git a/app/src/Spotlights.js b/app/src/Spotlights.js index 8b91e99..c683c34 100644 --- a/app/src/Spotlights.js +++ b/app/src/Spotlights.js @@ -5,11 +5,11 @@ const logger = new Logger('Spotlight'); export default class Spotlights extends EventEmitter { - constructor(maxSpotlights, room) + constructor(maxSpotlights, signalingSocket) { super(); - this._room = room; + this._signalingSocket = signalingSocket; this._maxSpotlights = maxSpotlights; this._peerList = []; this._selectedSpotlights = []; @@ -19,24 +19,25 @@ export default class Spotlights extends EventEmitter start() { - const peers = this._room.peers; - - for (const peer of peers) - { - this._handlePeer(peer); - } - - this._handleRoom(); + this._handleSignaling(); this._started = true; this._spotlightsUpdated(); } - peerInSpotlights(peerName) + addPeers(peers) + { + for (const peer of peers) + { + this._newPeer(peer.id); + } + } + + peerInSpotlights(peerId) { if (this._started) { - return this._currentSpotlights.indexOf(peerName) !== -1; + return this._currentSpotlights.indexOf(peerId) !== -1; } else { @@ -44,11 +45,11 @@ export default class Spotlights extends EventEmitter } } - setPeerSpotlight(peerName) + setPeerSpotlight(peerId) { - logger.debug('setPeerSpotlight() [peerName:"%s"]', peerName); + logger.debug('setPeerSpotlight() [peerId:"%s"]', peerId); - const index = this._selectedSpotlights.indexOf(peerName); + const index = this._selectedSpotlights.indexOf(peerId); if (index !== -1) { @@ -56,13 +57,13 @@ export default class Spotlights extends EventEmitter } else { - this._selectedSpotlights = [ peerName ]; + this._selectedSpotlights = [ peerId ]; } /* if (index === -1) // We don't have this peer in the list, adding { - this._selectedSpotlights.push(peerName); + this._selectedSpotlights.push(peerId); } else // We have this peer, remove { @@ -74,16 +75,65 @@ export default class Spotlights extends EventEmitter this._spotlightsUpdated(); } - _handleRoom() + _handleSignaling() { - this._room.on('newpeer', (peer) => + this._signalingSocket.on('notification', (notification) => { - logger.debug( - 'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer); - this._handlePeer(peer); + if (notification.method === 'newPeer') + { + const { id } = notification.data; + + this._newPeer(id); + } + + if (notification.method === 'peerClosed') + { + const { peerId } = notification.data; + + this._closePeer(peerId); + } }); } + _newPeer(id) + { + logger.debug( + 'room "newpeer" event [id: "%s"]', id); + + if (this._peerList.indexOf(id) === -1) // We don't have this peer in the list + { + logger.debug('_handlePeer() | adding peer [peerId: "%s"]', id); + + this._peerList.push(id); + + if (this._started) + this._spotlightsUpdated(); + } + } + + _closePeer(id) + { + logger.debug( + 'room "peerClosed" event [peerId:%o]', id); + + let index = this._peerList.indexOf(id); + + if (index !== -1) // We have this peer in the list, remove + { + this._peerList.splice(index, 1); + } + + index = this._selectedSpotlights.indexOf(id); + + if (index !== -1) // We have this peer in the list, remove + { + this._selectedSpotlights.splice(index, 1); + } + + if (this._started) + this._spotlightsUpdated(); + } + addSpeakerList(speakerList) { this._peerList = [ ...new Set([ ...speakerList, ...this._peerList ]) ]; @@ -92,49 +142,16 @@ export default class Spotlights extends EventEmitter this._spotlightsUpdated(); } - _handlePeer(peer) + handleActiveSpeaker(peerId) { - logger.debug('_handlePeer() [peerName:"%s"]', peer.name); + logger.debug('handleActiveSpeaker() [peerId:"%s"]', peerId); - 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); + const index = this._peerList.indexOf(peerId); if (index > -1) { this._peerList.splice(index, 1); - this._peerList = [ peerName ].concat(this._peerList); + this._peerList = [ peerId ].concat(this._peerList); this._spotlightsUpdated(); } diff --git a/app/src/actions/stateActions.js b/app/src/actions/stateActions.js index d81d433..fda3ceb 100644 --- a/app/src/actions/stateActions.js +++ b/app/src/actions/stateActions.js @@ -14,11 +14,11 @@ export const setRoomState = (state) => }; }; -export const setRoomActiveSpeaker = (peerName) => +export const setRoomActiveSpeaker = (peerId) => { return { type : 'SET_ROOM_ACTIVE_SPEAKER', - payload : { peerName } + payload : { peerId } }; }; @@ -43,41 +43,30 @@ export const setRoomLockedOut = () => }; }; -export const setAudioSuspended = ({ audioSuspended }) => -{ - return { - type : 'SET_AUDIO_SUSPENDED', - payload : { audioSuspended } - }; -}; - export const setSettingsOpen = ({ settingsOpen }) => ({ type : 'SET_SETTINGS_OPEN', payload : { settingsOpen } }); -export const setMe = ({ peerName, device, loginEnabled }) => +export const setMe = ({ peerId, device, loginEnabled }) => { return { type : 'SET_ME', - payload : { peerName, device, loginEnabled } + payload : { peerId, device, loginEnabled } }; }; -export const setMediaCapabilities = ({ canSendMic, canSendWebcam }) => +export const setMediaCapabilities = ({ + canSendMic, + canSendWebcam, + canShareScreen, + canShareFiles +}) => { return { type : 'SET_MEDIA_CAPABILITIES', - payload : { canSendMic, canSendWebcam } - }; -}; - -export const setScreenCapabilities = ({ canShareScreen, needExtension }) => -{ - return { - type : 'SET_SCREEN_CAPABILITIES', - payload : { canShareScreen, needExtension } + payload : { canSendMic, canSendWebcam, canShareScreen, canShareFiles } }; }; @@ -150,27 +139,27 @@ export const setDisplayMode = (mode) => payload : { mode } }); -export const setPeerVideoInProgress = (peerName, flag) => +export const setPeerVideoInProgress = (peerId, flag) => { return { type : 'SET_PEER_VIDEO_IN_PROGRESS', - payload : { peerName, flag } + payload : { peerId, flag } }; }; -export const setPeerAudioInProgress = (peerName, flag) => +export const setPeerAudioInProgress = (peerId, flag) => { return { type : 'SET_PEER_AUDIO_IN_PROGRESS', - payload : { peerName, flag } + payload : { peerId, flag } }; }; -export const setPeerScreenInProgress = (peerName, flag) => +export const setPeerScreenInProgress = (peerId, flag) => { return { type : 'SET_PEER_SCREEN_IN_PROGRESS', - payload : { peerName, flag } + payload : { peerId, flag } }; }; @@ -226,11 +215,11 @@ export const setMyRaiseHandStateInProgress = (flag) => }; }; -export const setPeerRaiseHandState = (peerName, raiseHandState) => +export const setPeerRaiseHandState = (peerId, raiseHandState) => { return { type : 'SET_PEER_RAISE_HAND_STATE', - payload : { peerName, raiseHandState } + payload : { peerId, raiseHandState } }; }; @@ -274,6 +263,14 @@ export const setProducerTrack = (producerId, track) => }; }; +export const setProducerScore = (producerId, score) => +{ + return { + type : 'SET_PRODUCER_SCORE', + payload : { producerId, score } + }; +}; + export const setAudioInProgress = (flag) => { return { @@ -306,35 +303,35 @@ export const addPeer = (peer) => }; }; -export const removePeer = (peerName) => +export const removePeer = (peerId) => { return { type : 'REMOVE_PEER', - payload : { peerName } + payload : { peerId } }; }; -export const setPeerDisplayName = (displayName, peerName) => +export const setPeerDisplayName = (displayName, peerId) => { return { type : 'SET_PEER_DISPLAY_NAME', - payload : { displayName, peerName } + payload : { displayName, peerId } }; }; -export const addConsumer = (consumer, peerName) => +export const addConsumer = (consumer, peerId) => { return { type : 'ADD_CONSUMER', - payload : { consumer, peerName } + payload : { consumer, peerId } }; }; -export const removeConsumer = (consumerId, peerName) => +export const removeConsumer = (consumerId, peerId) => { return { type : 'REMOVE_CONSUMER', - payload : { consumerId, peerName } + payload : { consumerId, peerId } }; }; @@ -354,11 +351,19 @@ export const setConsumerResumed = (consumerId, originator) => }; }; -export const setConsumerEffectiveProfile = (consumerId, profile) => +export const setConsumerCurrentLayers = (consumerId, spatialLayer, temporalLayer) => { return { - type : 'SET_CONSUMER_EFFECTIVE_PROFILE', - payload : { consumerId, profile } + type : 'SET_CONSUMER_CURRENT_LAYERS', + payload : { consumerId, spatialLayer, temporalLayer } + }; +}; + +export const setConsumerPreferredLayers = (consumerId, spatialLayer, temporalLayer) => +{ + return { + type : 'SET_CONSUMER_PREFERRED_LAYERS', + payload : { consumerId, spatialLayer, temporalLayer } }; }; @@ -370,11 +375,19 @@ export const setConsumerTrack = (consumerId, track) => }; }; -export const setPeerVolume = (peerName, volume) => +export const setConsumerScore = (consumerId, score) => +{ + return { + type : 'SET_CONSUMER_SCORE', + payload : { consumerId, score } + }; +}; + +export const setPeerVolume = (peerId, volume) => { return { type : 'SET_PEER_VOLUME', - payload : { peerName, volume } + payload : { peerId, volume } }; }; @@ -482,11 +495,11 @@ export const dropMessages = () => }; }; -export const addFile = (file) => +export const addFile = (peerId, magnetUri) => { return { type : 'ADD_FILE', - payload : { file } + payload : { peerId, magnetUri } }; }; @@ -536,10 +549,10 @@ export const setPicture = (picture) => payload : { picture } }); -export const setPeerPicture = (peerName, picture) => +export const setPeerPicture = (peerId, picture) => ({ type : 'SET_PEER_PICTURE', - payload : { peerName, picture } + payload : { peerId, picture } }); export const loggedIn = () => @@ -547,10 +560,15 @@ export const loggedIn = () => type : 'LOGGED_IN' }); -export const setSelectedPeer = (selectedPeerName) => +export const toggleJoined = () => + ({ + type : 'TOGGLE_JOINED' + }); + +export const setSelectedPeer = (selectedPeerId) => ({ type : 'SET_SELECTED_PEER', - payload : { selectedPeerName } + payload : { selectedPeerId } }); export const setSpotlights = (spotlights) => diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 3739582..8d3e465 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -1,20 +1,27 @@ -import React from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { meProducersSelector } from '../Selectors'; import { withRoomContext } from '../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import * as appPropTypes from '../appPropTypes'; import VideoView from '../VideoContainers/VideoView'; import Volume from './Volume'; +import Fab from '@material-ui/core/Fab'; +import Tooltip from '@material-ui/core/Tooltip'; +import MicIcon from '@material-ui/icons/Mic'; +import MicOffIcon from '@material-ui/icons/MicOff'; +import VideoIcon from '@material-ui/icons/Videocam'; +import VideoOffIcon from '@material-ui/icons/VideocamOff'; +import ScreenIcon from '@material-ui/icons/ScreenShare'; +import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; -const styles = () => +const styles = (theme) => ({ root : { - flexDirection : 'row', - margin : 6, flex : '0 0 auto', boxShadow : 'var(--peer-shadow)', border : 'var(--peer-border)', @@ -23,38 +30,88 @@ const styles = () => backgroundPosition : 'bottom', backgroundSize : 'auto 85%', backgroundRepeat : 'no-repeat', + '&.webcam' : + { + order : 1 + }, + '&.screen' : + { + order : 2 + }, + '&.hover' : + { + boxShadow : '0px 1px 3px rgba(0, 0, 0, 0.05) inset, 0px 0px 8px rgba(82, 168, 236, 0.9)' + }, '&.active-speaker' : { borderColor : 'var(--active-speaker-border-color)' } }, + fab : + { + margin : theme.spacing(1), + pointerEvents : 'auto' + }, viewContainer : { - position : 'relative', - '&.webcam' : + position : 'relative', + width : '100%', + height : '100%' + }, + controls : + { + position : 'absolute', + width : '100%', + height : '100%', + backgroundColor : 'rgba(0, 0, 0, 0.3)', + display : 'flex', + flexDirection : 'column', + justifyContent : 'center', + alignItems : 'flex-end', + padding : theme.spacing(1), + zIndex : 21, + opacity : 0, + transition : 'opacity 0.3s', + touchAction : 'none', + pointerEvents : 'none', + '&.hover' : { - order : 2 + opacity : 1 }, - '&.screen' : + '& p' : { - order : 1 + position : 'absolute', + float : 'left', + top : '50%', + left : '50%', + transform : 'translate(-50%, -50%)', + color : 'rgba(255, 255, 255, 0.5)', + fontSize : '7em', + margin : 0 } } }); const Me = (props) => { + const [ hover, setHover ] = useState(false); + + let touchTimeout = null; + const { roomClient, me, settings, activeSpeaker, + spacing, style, + smallButtons, advancedMode, micProducer, webcamProducer, screenProducer, - classes + classes, + theme } = props; const videoVisible = ( @@ -69,17 +126,225 @@ const Me = (props) => !screenProducer.remotelyPaused ); + let micState; + + let micTip; + + if (!me.canSendMic) + { + micState = 'unsupported'; + micTip = 'Audio unsupported'; + } + else if (!micProducer) + { + micState = 'off'; + micTip = 'Activate audio'; + } + else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) + { + micState = 'on'; + micTip = 'Mute audio'; + } + else + { + micState = 'muted'; + micTip = 'Unmute audio'; + } + + let webcamState; + + let webcamTip; + + if (!me.canSendWebcam) + { + webcamState = 'unsupported'; + webcamTip = 'Video unsupported'; + } + else if (webcamProducer) + { + webcamState = 'on'; + webcamTip = 'Stop video'; + } + else + { + webcamState = 'off'; + webcamTip = 'Start video'; + } + + let screenState; + + let screenTip; + + if (!me.canShareScreen) + { + screenState = 'unsupported'; + screenTip = 'Screen sharing not supported'; + } + else if (screenProducer) + { + screenState = 'on'; + screenTip = 'Stop screen sharing'; + } + else + { + screenState = 'off'; + screenTip = 'Start screen sharing'; + } + + const spacingStyle = + { + 'margin' : spacing + }; + + const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); + return (
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => + { + setHover(false); + }, 2000); + }} + style={spacingStyle} > -
+
+
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => + { + setHover(false); + }, 2000); + }} + > +

ME

+ +
+ + { + if (micState === 'off') + roomClient.enableMic(); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + +
+
+ +
+ + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + +
+
+ +
+ + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { screenState === 'on' || screenState === 'unsupported' ? + + :null + } + { screenState === 'off' ? + + :null + } + +
+
+
+ roomClient.changeDisplayName(displayName); }} > - +
{ screenProducer ? -
-
+
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => + { + setHover(false); + }, 2000); + }} + style={spacingStyle} + > +
+
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { + + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => + { + setHover(false); + }, 2000); + }} + > +

ME

+
+ @@ -138,7 +455,7 @@ const mapStateToProps = (state) => me : state.me, ...meProducersSelector(state), settings : state.settings, - activeSpeaker : state.me.name === state.room.activeSpeakerName + activeSpeaker : state.me.id === state.room.activeSpeakerId }; }; @@ -153,8 +470,8 @@ export default withRoomContext(connect( prev.me === next.me && prev.producers === next.producers && prev.settings === next.settings && - prev.room.activeSpeakerName === next.room.activeSpeakerName + prev.room.activeSpeakerId === next.room.activeSpeakerId ); } } -)(withStyles(styles)(Me))); +)(withStyles(styles, { withTheme: true })(Me))); diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index d83100c..ee7fd2e 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; -import { unstable_useMediaQuery as useMediaQuery } from '@material-ui/core/useMediaQuery'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; import * as stateActions from '../../actions/stateActions'; import VideoView from '../VideoContainers/VideoView'; import Fab from '@material-ui/core/Fab'; @@ -21,7 +21,6 @@ const styles = (theme) => root : { flex : '0 0 auto', - margin : 6, boxShadow : 'var(--peer-shadow)', border : 'var(--peer-border)', touchAction : 'none', @@ -32,11 +31,11 @@ const styles = (theme) => backgroundRepeat : 'no-repeat', '&.webcam' : { - order : 2 + order : 4 }, '&.screen' : { - order : 1 + order : 3 }, '&.hover' : { @@ -49,19 +48,13 @@ const styles = (theme) => }, fab : { - margin : theme.spacing.unit + margin : theme.spacing(1) }, viewContainer : { - position : 'relative', - '&.webcam' : - { - order : 2 - }, - '&.screen' : - { - order : 1 - } + position : 'relative', + width : '100%', + height : '100%' }, controls : { @@ -73,8 +66,8 @@ const styles = (theme) => flexDirection : 'column', justifyContent : 'center', alignItems : 'flex-end', - padding : '0.4vmin', - zIndex : 20, + padding : theme.spacing(1), + zIndex : 21, opacity : 0, transition : 'opacity 0.3s', touchAction : 'none', @@ -92,8 +85,8 @@ const styles = (theme) => display : 'flex', justifyContent : 'center', alignItems : 'center', - padding : '0.4vmin', - zIndex : 21, + padding : theme.spacing(1), + zIndex : 20, '& p' : { padding : '6px 12px', @@ -109,15 +102,9 @@ const styles = (theme) => const Peer = (props) => { const [ hover, setHover ] = useState(false); - const [ webcamHover, setWebcamHover ] = useState(false); - const [ screenHover, setScreenHover ] = useState(false); let touchTimeout = null; - let touchWebcamTimeout = null; - - let touchScreenTimeout = null; - const { roomClient, advancedMode, @@ -128,7 +115,9 @@ const Peer = (props) => screenConsumer, toggleConsumerFullscreen, toggleConsumerWindow, + spacing, style, + smallButtons, windowConsumer, classes, theme @@ -164,6 +153,12 @@ const Peer = (props) => const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const rootStyle = + { + 'margin' : spacing, + ...style + }; + return (
setHover(false); }, 2000); }} + style={rootStyle} > -
- { videoVisible && !webcamConsumer.supported ? -
-

incompatible video

-
- :null - } - +
{ !videoVisible ?

this video is paused

@@ -210,79 +199,80 @@ const Peer = (props) => :null } - { videoVisible && webcamConsumer.supported ? -
setWebcamHover(true)} - onMouseOut={() => setWebcamHover(false)} - onTouchStart={() => +
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => { - if (touchWebcamTimeout) - clearTimeout(touchWebcamTimeout); - - setWebcamHover(true); - }} - onTouchEnd={() => + setHover(false); + }, 2000); + }} + > + { - if (touchWebcamTimeout) - clearTimeout(touchWebcamTimeout); - - touchWebcamTimeout = setTimeout(() => - { - setWebcamHover(false); - }, 2000); + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); }} > - - { - micEnabled ? - roomClient.modifyPeerConsumer(peer.name, 'mic', true) : - roomClient.modifyPeerConsumer(peer.name, 'mic', false); - }} - > - { micEnabled ? - - : - - } - - - { !smallScreen ? - - { - toggleConsumerWindow(webcamConsumer); - }} - > - - - :null + { micEnabled ? + + : + } + + { !smallScreen ? { - toggleConsumerFullscreen(webcamConsumer); + toggleConsumerWindow(webcamConsumer); }} > - + -
- :null - } + :null + } + + + { + toggleConsumerFullscreen(webcamConsumer); + }} + > + + +
audioCodec={micConsumer ? micConsumer.codec : null} videoCodec={webcamConsumer ? webcamConsumer.codec : null} > - +
@@ -322,43 +312,37 @@ const Peer = (props) => setHover(false); }, 2000); }} + style={rootStyle} > - { screenVisible && !screenConsumer.supported ? -
-

incompatible video

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

this video is paused

:null } - { screenVisible && screenConsumer.supported ? -
+ { screenVisible ? +
setScreenHover(true)} - onMouseOut={() => setScreenHover(false)} + className={classnames(classes.controls, hover ? 'hover' : null)} + onMouseOver={() => setHover(true)} + onMouseOut={() => setHover(false)} onTouchStart={() => { - if (touchScreenTimeout) - clearTimeout(touchScreenTimeout); + if (touchTimeout) + clearTimeout(touchTimeout); - setScreenHover(true); + setHover(true); }} onTouchEnd={() => { - if (touchScreenTimeout) - clearTimeout(touchScreenTimeout); + if (touchTimeout) + clearTimeout(touchTimeout); - touchScreenTimeout = setTimeout(() => + touchTimeout = setTimeout(() => { - setScreenHover(false); + setHover(false); }, 2000); }} > @@ -370,6 +354,7 @@ const Peer = (props) => !screenVisible || (windowConsumer === screenConsumer.id) } + size={smallButtons ? 'small' : 'large'} onClick={() => { toggleConsumerWindow(screenConsumer); @@ -384,6 +369,7 @@ const Peer = (props) => aria-label='Fullscreen' className={classes.fab} disabled={!screenVisible} + size={smallButtons ? 'small' : 'large'} onClick={() => { toggleConsumerFullscreen(screenConsumer); @@ -418,9 +404,11 @@ Peer.propTypes = micConsumer : appPropTypes.Consumer, webcamConsumer : appPropTypes.Consumer, screenConsumer : appPropTypes.Consumer, - windowConsumer : PropTypes.number, + windowConsumer : PropTypes.string, activeSpeaker : PropTypes.bool, + spacing : PropTypes.number, style : PropTypes.object, + smallButtons : PropTypes.bool, toggleConsumerFullscreen : PropTypes.func.isRequired, toggleConsumerWindow : PropTypes.func.isRequired, classes : PropTypes.object.isRequired, @@ -434,10 +422,10 @@ const makeMapStateToProps = (initialState, props) => const mapStateToProps = (state) => { return { - peer : state.peers[props.name], + peer : state.peers[props.id], ...getPeerConsumers(state, props), windowConsumer : state.room.windowConsumer, - activeSpeaker : props.name === state.room.activeSpeakerName + activeSpeaker : props.id === state.room.activeSpeakerId }; }; @@ -470,7 +458,7 @@ export default withRoomContext(connect( return ( prev.peers === next.peers && prev.consumers === next.consumers && - prev.room.activeSpeakerName === next.room.activeSpeakerName && + prev.room.activeSpeakerId === next.room.activeSpeakerId && prev.room.windowConsumer === next.room.windowConsumer ); } diff --git a/app/src/components/Containers/SpeakerPeer.js b/app/src/components/Containers/SpeakerPeer.js new file mode 100644 index 0000000..85aeb0b --- /dev/null +++ b/app/src/components/Containers/SpeakerPeer.js @@ -0,0 +1,210 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makePeerConsumerSelector } from '../Selectors'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import * as appPropTypes from '../appPropTypes'; +import { withStyles } from '@material-ui/core/styles'; +import VideoView from '../VideoContainers/VideoView'; +import Volume from './Volume'; + +const styles = (theme) => + ({ + root : + { + flex : '0 0 auto', + boxShadow : 'var(--peer-shadow)', + border : 'var(--peer-border)', + touchAction : 'none', + backgroundColor : 'var(--peer-bg-color)', + backgroundImage : 'var(--peer-empty-avatar)', + backgroundPosition : 'bottom', + backgroundSize : 'auto 85%', + backgroundRepeat : 'no-repeat', + '&.webcam' : + { + order : 2 + }, + '&.screen' : + { + order : 1 + } + }, + viewContainer : + { + position : 'relative', + '&.webcam' : + { + order : 2 + }, + '&.screen' : + { + order : 1 + } + }, + videoInfo : + { + position : 'absolute', + width : '100%', + height : '100%', + backgroundColor : 'rgba(0, 0, 0, 0.3)', + display : 'flex', + justifyContent : 'center', + alignItems : 'center', + padding : theme.spacing(1), + zIndex : 21, + '& p' : + { + padding : '6px 12px', + borderRadius : 6, + userSelect : 'none', + pointerEvents : 'none', + fontSize : 20, + color : 'rgba(255, 255, 255, 0.55)' + } + } + }); + +const SpeakerPeer = (props) => +{ + const { + advancedMode, + peer, + micConsumer, + webcamConsumer, + screenConsumer, + spacing, + style, + classes + } = props; + + const videoVisible = ( + Boolean(webcamConsumer) && + !webcamConsumer.locallyPaused && + !webcamConsumer.remotelyPaused + ); + + const screenVisible = ( + Boolean(screenConsumer) && + !screenConsumer.locallyPaused && + !screenConsumer.remotelyPaused + ); + + let videoProfile; + + if (webcamConsumer) + videoProfile = webcamConsumer.profile; + + let screenProfile; + + if (screenConsumer) + screenProfile = screenConsumer.profile; + + const spacingStyle = + { + 'margin' : spacing + }; + + return ( + +
+
+ { !videoVisible ? +
+

this video is paused

+
+ :null + } + + + + +
+
+ + { screenConsumer ? +
+ { !screenVisible ? +
+

this video is paused

+
+ :null + } + + { screenVisible ? +
+ +
+ :null + } +
+ :null + } +
+ ); +}; + +SpeakerPeer.propTypes = +{ + advancedMode : PropTypes.bool, + peer : appPropTypes.Peer, + micConsumer : appPropTypes.Consumer, + webcamConsumer : appPropTypes.Consumer, + screenConsumer : appPropTypes.Consumer, + spacing : PropTypes.number, + style : PropTypes.object, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state, props) => +{ + const getPeerConsumers = makePeerConsumerSelector(); + + return { + peer : state.peers[props.id], + ...getPeerConsumers(state, props) + }; +}; + +export default connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.peers === next.peers && + prev.consumers === next.consumers && + prev.room.activeSpeakerId === next.room.activeSpeakerId + ); + } + } +)(withStyles(styles, { withTheme: true })(SpeakerPeer)); diff --git a/app/src/components/Containers/Volume.js b/app/src/components/Containers/Volume.js index 7488a30..3c13a39 100644 --- a/app/src/components/Containers/Volume.js +++ b/app/src/components/Containers/Volume.js @@ -150,7 +150,7 @@ const makeMapStateToProps = (initialState, props) => const mapStateToProps = (state) => { return { - volume : state.peerVolumes[props.name] + volume : state.peerVolumes[props.id] }; }; diff --git a/app/src/components/Controls/Sidebar.js b/app/src/components/Controls/Sidebar.js deleted file mode 100644 index 406a9f3..0000000 --- a/app/src/components/Controls/Sidebar.js +++ /dev/null @@ -1,332 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { meProducersSelector } from '../Selectors'; -import { withStyles } from '@material-ui/core/styles'; -import { unstable_useMediaQuery as useMediaQuery } from '@material-ui/core/useMediaQuery'; -import classnames from 'classnames'; -import * as appPropTypes from '../appPropTypes'; -import { withRoomContext } from '../../RoomContext'; -import Fab from '@material-ui/core/Fab'; -import Tooltip from '@material-ui/core/Tooltip'; -import MicIcon from '@material-ui/icons/Mic'; -import MicOffIcon from '@material-ui/icons/MicOff'; -import VideoIcon from '@material-ui/icons/Videocam'; -import VideoOffIcon from '@material-ui/icons/VideocamOff'; -import ScreenIcon from '@material-ui/icons/ScreenShare'; -import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import LockIcon from '@material-ui/icons/Lock'; -import LockOpenIcon from '@material-ui/icons/LockOpen'; -import LeaveIcon from '@material-ui/icons/Cancel'; - -const styles = (theme) => - ({ - root : - { - position : 'fixed', - zIndex : 500, - display : 'flex', - [theme.breakpoints.up('md')] : - { - top : '50%', - transform : 'translate(0%, -50%)', - flexDirection : 'column', - justifyContent : 'center', - alignItems : 'center', - left : '1.0em', - width : '2.6em' - }, - [theme.breakpoints.down('sm')] : - { - flexDirection : 'row', - bottom : '0.5em', - left : '50%', - transform : 'translate(-50%, -0%)' - } - }, - fab : - { - margin : theme.spacing.unit - }, - show : - { - opacity : 1, - transition : 'opacity .5s' - }, - hide : - { - opacity : 0, - transition : 'opacity .5s' - } - }); - -const Sidebar = (props) => -{ - const { - roomClient, - toolbarsVisible, - me, - micProducer, - webcamProducer, - screenProducer, - locked, - classes, - theme - } = props; - - let micState; - - let micTip; - - if (!me.canSendMic || !micProducer) - { - micState = 'unsupported'; - micTip = 'Audio unsupported'; - } - else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) - { - micState = 'on'; - micTip = 'Mute audio'; - } - else - { - micState = 'off'; - micTip = 'Unmute audio'; - } - - let webcamState; - - let webcamTip; - - if (!me.canSendWebcam) - { - webcamState = 'unsupported'; - webcamTip = 'Video unsupported'; - } - else if (webcamProducer) - { - webcamState = 'on'; - webcamTip = 'Stop video'; - } - else - { - webcamState = 'off'; - webcamTip = 'Start video'; - } - - let screenState; - - let screenTip; - - if (me.needExtension) - { - screenState = 'need-extension'; - screenTip = 'Install screen sharing extension'; - } - else if (!me.canShareScreen) - { - screenState = 'unsupported'; - screenTip = 'Screen sharing not supported'; - } - else if (screenProducer) - { - screenState = 'on'; - screenTip = 'Stop screen sharing'; - } - else - { - screenState = 'off'; - screenTip = 'Start screen sharing'; - } - - const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); - - return ( -
- - - { - micState === 'on' ? - roomClient.muteMic() : - roomClient.unmuteMic(); - }} - > - { micState === 'on' ? - - : - - } - - - - - { - webcamState === 'on' ? - roomClient.disableWebcam() : - roomClient.enableWebcam(); - }} - > - { webcamState === 'on' ? - - : - - } - - - - - { - switch (screenState) - { - case 'on': - { - roomClient.disableScreenSharing(); - break; - } - case 'off': - { - roomClient.enableScreenSharing(); - break; - } - case 'need-extension': - { - roomClient.installExtension(); - break; - } - default: - { - break; - } - } - }} - > - { screenState === 'on' || screenState === 'unsupported' ? - - :null - } - { screenState === 'off' ? - - :null - } - { screenState === 'need-extension' ? - - :null - } - - - - - - { - if (locked) - { - roomClient.unlockRoom(); - } - else - { - roomClient.lockRoom(); - } - }} - > - { locked ? - - : - - } - - - - { /* roomClient.sendRaiseHandState(!me.raiseHand)} - > - - */ } - - - roomClient.close()} - > - - - -
- ); -}; - -Sidebar.propTypes = -{ - roomClient : PropTypes.any.isRequired, - toolbarsVisible : PropTypes.bool.isRequired, - me : appPropTypes.Me.isRequired, - micProducer : appPropTypes.Producer, - webcamProducer : appPropTypes.Producer, - screenProducer : appPropTypes.Producer, - locked : PropTypes.bool.isRequired, - classes : PropTypes.object.isRequired, - theme : PropTypes.object.isRequired -}; - -const mapStateToProps = (state) => - ({ - toolbarsVisible : state.room.toolbarsVisible, - ...meProducersSelector(state), - me : state.me, - locked : state.room.locked - }); - -export default withRoomContext(connect( - mapStateToProps, - null, - null, - { - areStatesEqual : (next, prev) => - { - return ( - prev.room.toolbarsVisible === next.room.toolbarsVisible && - prev.room.locked === next.room.locked && - prev.producers === next.producers && - prev.me === next.me - ); - } - } -)(withStyles(styles, { withTheme: true })(Sidebar))); diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js new file mode 100644 index 0000000..ae11771 --- /dev/null +++ b/app/src/components/JoinDialog.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../RoomContext'; +import PropTypes from 'prop-types'; +import Dialog from '@material-ui/core/Dialog'; +import Typography from '@material-ui/core/Typography'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + }, + dialogPaper : + { + width : '20vw', + padding : theme.spacing(2), + [theme.breakpoints.down('lg')] : + { + width : '30vw' + }, + [theme.breakpoints.down('md')] : + { + width : '40vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '60vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '80vw' + } + }, + logo : + { + display : 'block' + } + }); + +const JoinDialog = ({ + roomClient, + classes +}) => +{ + return ( + + { window.config.logo ? + Logo + :null + } + You are about to join a meeting, how would you like to join? + + + + + + ); +}; + +JoinDialog.propTypes = +{ + roomClient : PropTypes.any.isRequired, + classes : PropTypes.object.isRequired +}; + +export default withRoomContext(withStyles(styles)(JoinDialog)); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/Chat/ChatInput.js b/app/src/components/MeetingDrawer/Chat/ChatInput.js index 07d7fe5..bafaa20 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatInput.js +++ b/app/src/components/MeetingDrawer/Chat/ChatInput.js @@ -12,7 +12,7 @@ const styles = (theme) => ({ root : { - padding : theme.spacing.unit, + padding : theme.spacing(1), display : 'flex', alignItems : 'center', borderRadius : 0 diff --git a/app/src/components/MeetingDrawer/Chat/Message.js b/app/src/components/MeetingDrawer/Chat/Message.js index f00fa01..856ac92 100644 --- a/app/src/components/MeetingDrawer/Chat/Message.js +++ b/app/src/components/MeetingDrawer/Chat/Message.js @@ -21,8 +21,8 @@ const styles = (theme) => root : { display : 'flex', - marginBottom : theme.spacing.unit, - padding : theme.spacing.unit, + marginBottom : theme.spacing(1), + padding : theme.spacing(1), flexShrink : 0 }, selfMessage : @@ -42,7 +42,7 @@ const styles = (theme) => }, content : { - marginLeft : theme.spacing.unit + marginLeft : theme.spacing(1) }, avatar : { diff --git a/app/src/components/MeetingDrawer/Chat/MessageList.js b/app/src/components/MeetingDrawer/Chat/MessageList.js index 8e174cc..cdc8809 100644 --- a/app/src/components/MeetingDrawer/Chat/MessageList.js +++ b/app/src/components/MeetingDrawer/Chat/MessageList.js @@ -14,7 +14,7 @@ const styles = (theme) => flexDirection : 'column', alignItems : 'center', overflowY : 'auto', - padding : theme.spacing.unit + padding : theme.spacing(1) } }); diff --git a/app/src/components/MeetingDrawer/FileSharing/File.js b/app/src/components/MeetingDrawer/FileSharing/File.js index c532ef5..cd3a985 100644 --- a/app/src/components/MeetingDrawer/FileSharing/File.js +++ b/app/src/components/MeetingDrawer/FileSharing/File.js @@ -6,7 +6,6 @@ import { withStyles } from '@material-ui/core/styles'; import magnet from 'magnet-uri'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; -import EmptyAvatar from '../../../images/avatar-empty.jpeg'; const styles = (theme) => ({ @@ -15,11 +14,11 @@ const styles = (theme) => display : 'flex', alignItems : 'center', width : '100%', - padding : theme.spacing.unit, + padding : theme.spacing(1), boxShadow : '0px 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12)', '&:not(:last-child)' : { - marginBottom : theme.spacing.unit + marginBottom : theme.spacing(1) } }, avatar : @@ -30,7 +29,7 @@ const styles = (theme) => text : { margin : 0, - padding : theme.spacing.unit + padding : theme.spacing(1) }, fileContent : { @@ -41,7 +40,7 @@ const styles = (theme) => { display : 'flex', alignItems : 'center', - padding : theme.spacing.unit + padding : theme.spacing(1) }, button : { @@ -55,15 +54,18 @@ class File extends React.PureComponent { const { roomClient, - torrentSupport, + displayName, + picture, + canShareFiles, + magnetUri, file, classes } = this.props; return (
- Peer avatar - + Avatar +
{ file.files ? @@ -93,26 +95,22 @@ class File extends React.PureComponent :null } - { file.me ? - 'You shared a file' - : - `${file.displayName} shared a file` - } + { `${displayName} shared a file` } { !file.active && !file.files ?
- {magnet.decode(file.magnetUri).dn} + { magnet.decode(magnetUri).dn } - { torrentSupport ? + { canShareFiles ? @@ -80,17 +80,17 @@ class FileSharing extends React.PureComponent } FileSharing.propTypes = { - roomClient : PropTypes.any.isRequired, - torrentSupport : PropTypes.bool.isRequired, - tabOpen : PropTypes.bool.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + canShareFiles : PropTypes.bool.isRequired, + tabOpen : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - torrentSupport : state.room.torrentSupport, - tabOpen : state.toolarea.currentToolTab === 'files' + canShareFiles : state.me.canShareFiles, + tabOpen : state.toolarea.currentToolTab === 'files' }; }; diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index b33ffc3..f506f9c 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -7,11 +7,11 @@ import * as appPropTypes from '../../appPropTypes'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import HandIcon from '../../../images/icon-hand-white.svg'; -const styles = () => +const styles = (theme) => ({ root : { - padding : '0.5rem', + padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', @@ -31,7 +31,7 @@ const styles = () => fontSize : '1rem', border : 'none', display : 'flex', - paddingLeft : '0.5rem', + paddingLeft : theme.spacing(1), flexGrow : 1, alignItems : 'center' }, diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 59ab7ec..82f6840 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -13,11 +13,11 @@ import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import HandIcon from '../../../images/icon-hand-white.svg'; -const styles = () => +const styles = (theme) => ({ root : { - padding : '0.5rem', + padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', @@ -37,7 +37,7 @@ const styles = () => fontSize : '1rem', border : 'none', display : 'flex', - paddingLeft : '0.5rem', + paddingLeft : theme.spacing(1), flexGrow : 1, alignItems : 'center' }, @@ -185,8 +185,8 @@ const ListPeer = (props) => { e.stopPropagation(); screenVisible ? - roomClient.modifyPeerConsumer(peer.name, 'screen', true) : - roomClient.modifyPeerConsumer(peer.name, 'screen', false); + roomClient.modifyPeerConsumer(peer.id, 'screen', true) : + roomClient.modifyPeerConsumer(peer.id, 'screen', false); }} > { screenVisible ? @@ -207,8 +207,8 @@ const ListPeer = (props) => { e.stopPropagation(); micEnabled ? - roomClient.modifyPeerConsumer(peer.name, 'mic', true) : - roomClient.modifyPeerConsumer(peer.name, 'mic', false); + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); }} > { micEnabled ? @@ -241,7 +241,7 @@ const makeMapStateToProps = (initialState, props) => const mapStateToProps = (state) => { return { - peer : state.peers[props.name], + peer : state.peers[props.id], ...getPeerConsumers(state, props) }; }; diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index a7d5213..a4f66ed 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -18,23 +18,23 @@ const styles = (theme) => { width : '100%', overflowY : 'auto', - padding : 6 + padding : theme.spacing(1) }, list : { listStyleType : 'none', - padding : theme.spacing.unit, + padding : theme.spacing(1), boxShadow : '0 2px 5px 2px rgba(0, 0, 0, 0.2)', backgroundColor : 'rgba(255, 255, 255, 1)' }, listheader : { - padding : '0.5rem', + padding : theme.spacing(1), fontWeight : 'bolder' }, listItem : { - padding : '0.5rem', + padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'pointer', @@ -76,7 +76,7 @@ class ParticipantList extends React.PureComponent roomClient, advancedMode, passivePeers, - selectedPeerName, + selectedPeerId, spotlightPeers, classes } = this.props; @@ -92,14 +92,14 @@ class ParticipantList extends React.PureComponent
  • Participants in Spotlight:
  • { spotlightPeers.map((peer) => (
  • roomClient.setSelectedPeer(peer.name)} + onClick={() => roomClient.setSelectedPeer(peer.id)} > - - + +
  • ))} @@ -107,15 +107,15 @@ class ParticipantList extends React.PureComponent
    • Passive Participants:
    • - { passivePeers.map((peerName) => ( + { passivePeers.map((peerId) => (
    • roomClient.setSelectedPeer(peerName)} + onClick={() => roomClient.setSelectedPeer(peerId)} > - +
    • ))}
    @@ -126,20 +126,20 @@ class ParticipantList extends React.PureComponent ParticipantList.propTypes = { - roomClient : PropTypes.any.isRequired, - advancedMode : PropTypes.bool, - passivePeers : PropTypes.array, - selectedPeerName : PropTypes.string, - spotlightPeers : PropTypes.array, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + advancedMode : PropTypes.bool, + passivePeers : PropTypes.array, + selectedPeerId : PropTypes.string, + spotlightPeers : PropTypes.array, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - passivePeers : passivePeersSelector(state), - selectedPeerName : state.room.selectedPeerName, - spotlightPeers : spotlightPeersSelector(state) + passivePeers : passivePeersSelector(state), + selectedPeerId : state.room.selectedPeerId, + spotlightPeers : spotlightPeersSelector(state) }; }; @@ -153,7 +153,7 @@ const ParticipantListContainer = withRoomContext(connect( return ( prev.peers === next.peers && prev.room.spotlights === next.room.spotlights && - prev.room.selectedPeerName === next.room.selectedPeerName + prev.room.selectedPeerId === next.room.selectedPeerId ); } } diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js index ca91ad8..e618719 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -7,12 +7,10 @@ import { spotlightsLengthSelector } from '../Selectors'; import PropTypes from 'prop-types'; -import debounce from 'lodash/debounce'; import { withStyles } from '@material-ui/core/styles'; import Peer from '../Containers/Peer'; import Me from '../Containers/Me'; import HiddenPeers from '../Containers/HiddenPeers'; -import ResizeObserver from 'resize-observer-polyfill'; const RATIO = 1.334; const PADDING_V = 50; @@ -43,15 +41,14 @@ class Democratic extends React.PureComponent { super(props); - this.state = { - peerWidth : 400, - peerHeight : 300 - }; + this.state = {}; + + this.resizeTimeout = null; this.peersRef = React.createRef(); } - updateDimensions = debounce(() => + updateDimensions = () => { if (!this.peersRef.current) { @@ -93,14 +90,21 @@ class Democratic extends React.PureComponent peerHeight : 0.9 * y }); } - }, 200); + }; componentDidMount() { - window.addEventListener('resize', this.updateDimensions); - const observer = new ResizeObserver(this.updateDimensions); + // window.resize event listener + window.addEventListener('resize', () => + { + // clear the timeout + clearTimeout(this.resizeTimeout); - observer.observe(this.peersRef.current); + // start timing for event "completion" + this.resizeTimeout = setTimeout(() => this.updateDimensions(), 250); + }); + + this.updateDimensions(); } componentWillUnmount() @@ -108,9 +112,10 @@ class Democratic extends React.PureComponent window.removeEventListener('resize', this.updateDimensions); } - componentDidUpdate() + componentDidUpdate(prevProps) { - this.updateDimensions(); + if (prevProps !== this.props) + this.updateDimensions(); } render() @@ -125,23 +130,25 @@ class Democratic extends React.PureComponent const style = { - 'width' : this.state.peerWidth, - 'height' : this.state.peerHeight + 'width' : this.state.peerWidth ? this.state.peerWidth : 0, + 'height' : this.state.peerHeight ? this.state.peerHeight : 0 }; return (
    { spotlightsPeers.map((peer) => { return ( ); diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index 3f76809..934db64 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -1,89 +1,54 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ResizeObserver from 'resize-observer-polyfill'; import { connect } from 'react-redux'; -import debounce from 'lodash/debounce'; import { withStyles } from '@material-ui/core/styles'; import classnames from 'classnames'; +import { + spotlightsLengthSelector, + videoBoxesSelector +} from '../Selectors'; import { withRoomContext } from '../../RoomContext'; +import Me from '../Containers/Me'; import Peer from '../Containers/Peer'; +import SpeakerPeer from '../Containers/SpeakerPeer'; import HiddenPeers from '../Containers/HiddenPeers'; +import Grid from '@material-ui/core/Grid'; const styles = () => ({ root : { - display : 'flex', - flexDirection : 'column', - alignItems : 'center', - height : '100%', - width : '100%' + height : '100%', + width : '100%', + display : 'grid', + gridTemplateColumns : '1fr', + gridTemplateRows : '1.6fr minmax(0, 0.4fr)' }, - activePeerContainer : + speaker : { - width : '100%', - height : '80vh', + gridArea : '1 / 1 / 2 / 2', display : 'flex', justifyContent : 'center', - alignItems : 'center' - }, - activePeer : - { - width : '100%', - border : '5px solid rgba(255, 255, 255, 0.15)', - boxShadow : '0px 5px 12px 2px rgba(17, 17, 17, 0.5)', - marginTop : 60 + alignItems : 'center', + paddingTop : 40 }, filmStrip : { - display : 'flex', - background : 'rgba(0, 0, 0 , 0.5)', - width : '100%', - overflowX : 'auto', - height : '20vh', - alignItems : 'center' + gridArea : '2 / 1 / 3 / 2' }, - filmStripContent : + filmItem : { - margin : '0 auto', - display : 'flex', - height : '100%', - alignItems : 'center' - }, - film : - { - height : '18vh', - flexShrink : 0, - paddingLeft : '1vh', - '& .active' : - { - borderColor : 'var(--active-speaker-border-color)' - }, + display : 'flex', + marginLeft : '6px', + border : 'var(--peer-border)', '&.selected' : { borderColor : 'var(--selected-peer-border-color)' }, - '&:last-child' : + '&.active' : { - paddingRight : '1vh' + opacity : '0.6' } - }, - filmContent : - { - height : '100%', - width : '100%', - border : '1px solid rgba(255,255,255,0.15)', - maxWidth : 'calc(18vh * (4 / 3))', - cursor : 'pointer', - '& .screen' : - { - maxWidth : 'calc(18vh * (2 * 4 / 3))', - border : 0 - } - }, - hiddenPeers : - { - } }); @@ -93,80 +58,114 @@ class Filmstrip extends React.PureComponent { super(props); + this.resizeTimeout = null; + this.activePeerContainer = React.createRef(); + + this.filmStripContainer = React.createRef(); } state = { - lastSpeaker : null, - width : 400 + lastSpeaker : null }; // Find the name of the peer which is currently speaking. This is either // the latest active speaker, or the manually selected peer, or, if no // person has spoken yet, the first peer in the list of peers. - getActivePeerName = () => + getActivePeerId = () => { - if (this.props.selectedPeerName) + const { + selectedPeerId, + peers + } = this.props; + + const { lastSpeaker } = this.state; + + if (selectedPeerId && peers[selectedPeerId]) { - return this.props.selectedPeerName; + return this.props.selectedPeerId; } - if (this.state.lastSpeaker) + if (lastSpeaker && peers[lastSpeaker]) { return this.state.lastSpeaker; } - const peerNames = Object.keys(this.props.peers); + const peerIds = Object.keys(peers); - if (peerNames.length > 0) + if (peerIds.length > 0) { - return peerNames[0]; + return peerIds[0]; } }; - isSharingCamera = (peerName) => this.props.peers[peerName] && - this.props.peers[peerName].consumers.some((consumer) => + isSharingCamera = (peerId) => this.props.peers[peerId] && + this.props.peers[peerId].consumers.some((consumer) => this.props.consumers[consumer].source === 'screen'); - getRatio = () => + updateDimensions = () => { - let ratio = 4 / 3; + const newState = {}; - if (this.isSharingCamera(this.getActivePeerName())) + const speaker = this.activePeerContainer.current; + + if (speaker) { - ratio *= 2; - } + let speakerWidth = (speaker.clientWidth - 100); - return ratio; - }; - - updateDimensions = debounce(() => - { - const container = this.activePeerContainer.current; - - if (container) - { - const ratio = this.getRatio(); - - let width = container.clientWidth; - - if (width / ratio > (container.clientHeight - 100)) + let speakerHeight = (speakerWidth / 4) * 3; + + if (this.isSharingCamera(this.getActivePeerId())) { - width = (container.clientHeight - 100) * ratio; + speakerWidth /= 2; + speakerHeight = (speakerWidth / 4) * 3; + } + + if (speakerHeight > (speaker.clientHeight - 60)) + { + speakerHeight = (speaker.clientHeight - 60); + speakerWidth = (speakerHeight / 3) * 4; } - this.setState({ - width - }); + newState.speakerWidth = speakerWidth; + newState.speakerHeight = speakerHeight; } - }, 200); + + const filmStrip = this.filmStripContainer.current; + + if (filmStrip) + { + let filmStripHeight = filmStrip.clientHeight - 10; + + let filmStripWidth = (filmStripHeight / 3) * 4; + + if (filmStripWidth * this.props.boxes > (filmStrip.clientWidth - 50)) + { + filmStripWidth = (filmStrip.clientWidth - 50) / this.props.boxes; + filmStripHeight = (filmStripWidth / 4) * 3; + } + + newState.filmStripWidth = filmStripWidth; + newState.filmStripHeight = filmStripHeight; + } + + this.setState({ + ...newState + }); + }; componentDidMount() { - window.addEventListener('resize', this.updateDimensions); - const observer = new ResizeObserver(this.updateDimensions); + // window.resize event listener + window.addEventListener('resize', () => + { + // clear the timeout + clearTimeout(this.resizeTimeout); + + // start timing for event "completion" + this.resizeTimeout = setTimeout(() => this.updateDimensions(), 250); + }); - observer.observe(this.activePeerContainer.current); this.updateDimensions(); } @@ -175,19 +174,28 @@ class Filmstrip extends React.PureComponent window.removeEventListener('resize', this.updateDimensions); } + componentWillUpdate(nextProps) + { + if (nextProps !== this.props) + { + if ( + nextProps.activeSpeakerId != null && + nextProps.activeSpeakerId !== this.props.myId + ) + { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + lastSpeaker : nextProps.activeSpeakerId + }); + } + } + } + componentDidUpdate(prevProps) { if (prevProps !== this.props) { this.updateDimensions(); - - if (this.props.activeSpeakerName !== this.props.myName) - { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - lastSpeaker : this.props.activeSpeakerName - }); - } } } @@ -196,56 +204,78 @@ class Filmstrip extends React.PureComponent const { roomClient, peers, + myId, advancedMode, spotlights, spotlightsLength, classes } = this.props; - const activePeerName = this.getActivePeerName(); + const activePeerId = this.getActivePeerId(); + + const speakerStyle = + { + width : this.state.speakerWidth, + height : this.state.speakerHeight + }; + + const peerStyle = + { + width : this.state.filmStripWidth, + height : this.state.filmStripHeight + }; return (
    -
    - { peers[activePeerName] ? -
    - -
    +
    + { peers[activePeerId] ? + :null }
    -
    -
    - { Object.keys(peers).map((peerName) => +
    + + +
    + +
    +
    + + { Object.keys(peers).map((peerId) => { - if (spotlights.find((spotlightsElement) => spotlightsElement === peerName)) + if (spotlights.find((spotlightsElement) => spotlightsElement === peerId)) { return ( -
    roomClient.setSelectedPeer(peerName)} - className={classnames(classes.film, { - selected : this.props.selectedPeerName === peerName, - active : this.state.lastSpeaker === peerName - })} - > -
    + +
    roomClient.setSelectedPeer(peerId)} + className={classnames(classes.filmItem, { + selected : this.props.selectedPeerId === peerId, + active : peerId === activePeerId + })} + >
    -
    + ); } else @@ -253,51 +283,63 @@ class Filmstrip extends React.PureComponent return (''); } })} -
    -
    -
    - { spotlightsLength - :null - } +
    + { spotlightsLength + :null + }
    ); } } Filmstrip.propTypes = { - roomClient : PropTypes.any.isRequired, - activeSpeakerName : PropTypes.string, - advancedMode : PropTypes.bool, - peers : PropTypes.object.isRequired, - consumers : PropTypes.object.isRequired, - myName : PropTypes.string.isRequired, - selectedPeerName : PropTypes.string, - spotlightsLength : PropTypes.number, - spotlights : PropTypes.array.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + activeSpeakerId : PropTypes.string, + advancedMode : PropTypes.bool, + peers : PropTypes.object.isRequired, + consumers : PropTypes.object.isRequired, + myId : PropTypes.string.isRequired, + selectedPeerId : PropTypes.string, + spotlightsLength : PropTypes.number, + spotlights : PropTypes.array.isRequired, + boxes : PropTypes.number, + classes : PropTypes.object.isRequired }; 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 + activeSpeakerId : state.room.activeSpeakerId, + selectedPeerId : state.room.selectedPeerId, + peers : state.peers, + consumers : state.consumers, + myId : state.me.id, + spotlights : state.room.spotlights, + spotlightsLength : spotlightsLengthSelector(state), + boxes : videoBoxesSelector(state), }; }; export default withRoomContext(connect( mapStateToProps, - undefined + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.activeSpeakerId === next.room.activeSpeakerId && + prev.room.selectedPeerId === next.room.selectedPeerId && + prev.peers === next.peers && + prev.consumers === next.consumers && + prev.room.spotlights === next.room.spotlights && + prev.me.id === next.me.id + ); + } + } )(withStyles(styles)(Filmstrip))); \ No newline at end of file diff --git a/app/src/components/Room.js b/app/src/components/Room.js index 8a503b7..040511e 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -15,7 +15,6 @@ import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'; import Hidden from '@material-ui/core/Hidden'; import Paper from '@material-ui/core/Paper'; import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Avatar from '@material-ui/core/Avatar'; @@ -28,11 +27,14 @@ import Filmstrip from './MeetingViews/Filmstrip'; import AudioPeers from './PeerAudio/AudioPeers'; import FullScreenView from './VideoContainers/FullScreenView'; import VideoWindow from './VideoWindow/VideoWindow'; -import Sidebar from './Controls/Sidebar'; import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; import SettingsIcon from '@material-ui/icons/Settings'; +import LockIcon from '@material-ui/icons/Lock'; +import LockOpenIcon from '@material-ui/icons/LockOpen'; +import Button from '@material-ui/core/Button'; import Settings from './Settings/Settings'; +import JoinDialog from './JoinDialog'; const TIMEOUT = 10 * 1000; @@ -58,7 +60,7 @@ const styles = (theme) => left : '50%', transform : 'translateX(-50%) translateY(-50%)', width : '30vw', - padding : theme.spacing.unit * 2, + padding : theme.spacing(2), flexDirection : 'column', justifyContent : 'center', alignItems : 'center' @@ -125,6 +127,11 @@ const styles = (theme) => { display : 'flex' }, + actionButton : + { + margin : theme.spacing(1), + padding : 0 + }, meContainer : { position : 'fixed', @@ -176,10 +183,6 @@ class Room extends React.PureComponent componentDidMount() { - const { roomClient } = this.props; - - roomClient.join(); - if (this.fullscreen.fullscreenEnabled) { this.fullscreen.addEventListener('fullscreenchange', this.handleFullscreenChange); @@ -242,29 +245,7 @@ class Room extends React.PureComponent democratic : Democratic }[room.mode]; - if (room.audioSuspended) - { - return ( -
    - - - This webpage required sound and video to play, please click to allow. - - - -
    - ); - } - else if (room.lockedOut) + if (room.lockedOut) { return (
    @@ -274,6 +255,14 @@ class Room extends React.PureComponent
    ); } + else if (!room.joined) + { + return ( +
    + +
    + ); + } else { return ( @@ -324,9 +313,32 @@ class Room extends React.PureComponent
    + + { + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } + }} + > + { room.locked ? + + : + + } + { this.fullscreen.fullscreenEnabled ? @@ -340,6 +352,7 @@ class Room extends React.PureComponent } setSettingsOpen(!room.settingsOpen)} > @@ -348,6 +361,7 @@ class Room extends React.PureComponent { loginEnabled ? { @@ -362,6 +376,15 @@ class Room extends React.PureComponent :null } +
    @@ -384,8 +407,6 @@ class Room extends React.PureComponent - -
    ); diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index c21c21f..95291f4 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -5,7 +5,7 @@ const consumersSelect = (state) => state.consumers; const spotlightsSelector = (state) => state.room.spotlights; const peersSelector = (state) => state.peers; const getPeerConsumers = (state, props) => - (state.peers[props.name] ? state.peers[props.name].consumers : null); + (state.peers[props.id] ? state.peers[props.id].consumers : null); const getAllConsumers = (state) => state.consumers; const peersKeySelector = createSelector( peersSelector, @@ -66,10 +66,10 @@ export const spotlightPeersSelector = createSelector( spotlightsSelector, peersSelector, (spotlights, peers) => - spotlights.reduce((result, peerName) => + spotlights.reduce((result, peerId) => { - if (peers[peerName]) - result.push(peers[peerName]); + if (peers[peerId]) + result.push(peers[peerId]); return result; }, []) @@ -83,7 +83,7 @@ export const peersLengthSelector = createSelector( export const passivePeersSelector = createSelector( peersKeySelector, spotlightsSelector, - (peers, spotlights) => peers.filter((peerName) => !spotlights.includes(peerName)) + (peers, spotlights) => peers.filter((peerId) => !spotlights.includes(peerId)) ); export const videoBoxesSelector = createSelector( diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 843d548..11411da 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -43,7 +43,7 @@ const styles = (theme) => }, setting : { - padding : theme.spacing.unit * 2 + padding : theme.spacing(2) }, formControl : { @@ -51,13 +51,13 @@ const styles = (theme) => } }); -/* const modes = [ { +const modes = [ { value : 'democratic', label : 'Democratic view' }, { value : 'filmstrip', label : 'Filmstrip view' -} ]; */ +} ]; const resolutions = [ { value : 'low', @@ -87,6 +87,7 @@ const Settings = ({ settings, onToggleAdvancedMode, handleCloseSettings, + handleChangeMode, classes }) => { @@ -203,6 +204,33 @@ const Settings = ({ +
    + + + + Select room layout + + +
    } @@ -241,7 +269,7 @@ const mapStateToProps = (state) => const mapDispatchToProps = { onToggleAdvancedMode : stateActions.toggleAdvancedMode, handleChangeMode : stateActions.setDisplayMode, - handleCloseSettings : stateActions.setSettingsOpen + handleCloseSettings : stateActions.setSettingsOpen, }; export default withRoomContext(connect( diff --git a/app/src/components/VideoContainers/FullScreenView.js b/app/src/components/VideoContainers/FullScreenView.js index 85a9e0d..6f99122 100644 --- a/app/src/components/VideoContainers/FullScreenView.js +++ b/app/src/components/VideoContainers/FullScreenView.js @@ -8,7 +8,7 @@ import * as stateActions from '../../actions/stateActions'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; import VideoView from './VideoView'; -const styles = () => +const styles = (theme) => ({ root : { @@ -29,7 +29,7 @@ const styles = () => flexDirection : 'row', justifyContent : 'flex-start', alignItems : 'center', - padding : '0.4vmin' + padding : theme.spacing(1) }, button : { @@ -102,13 +102,6 @@ const FullScreenView = (props) => return (
    - { consumerVisible && !consumer.supported ? -
    -

    incompatible video

    -
    - :null - } -
    +const styles = (theme) => ({ root : { @@ -27,7 +27,7 @@ const styles = () => transitionProperty : 'opacity', transitionDuration : '.15s', backgroundColor : 'var(--peer-video-bg-color)', - '&.is-me' : + '&.isMe' : { transform : 'scaleX(-1)' }, @@ -48,54 +48,42 @@ const styles = () => }, info : { + width : '100%', + height : '100%', + padding : theme.spacing(1), position : 'absolute', zIndex : 10, - top : '0.6vmin', - left : '0.6vmin', - bottom : 0, - right : 0, display : 'flex', flexDirection : 'column', justifyContent : 'space-between' }, media : { - flex : '0 0 auto', - display : 'flex', - flexDirection : 'row' + display : 'flex', + transitionProperty : 'opacity', + transitionDuration : '.15s', + '&.hidden' : + { + opacity : 0, + transitionDuration : '0s' + } }, box : { - padding : '0.4vmin', + padding : theme.spacing(0.5), borderRadius : 2, backgroundColor : 'rgba(0, 0, 0, 0.25)', '& p' : { - userSelect : 'none', - pointerEvents : 'none', - margin : 0, - color : 'rgba(255, 255, 255, 0.7)', - fontSize : 10, - - '&:last-child' : - { - marginBottom : 0 - } + userSelect : 'none', + margin : 0, + color : 'rgba(255, 255, 255, 0.7)', + fontSize : '0.8em' } }, peer : { - flex : '0 0 auto', - display : 'flex', - flexDirection : 'column', - justifyContent : 'flex-end', - position : 'absolute', - bottom : '0.6vmin', - left : 0, - borderRadius : 2, - backgroundColor : 'rgba(0, 0, 0, 0.25)', - padding : '0.5vmin', - alignItems : 'flex-start' + display : 'flex' }, displayNameEdit : { @@ -120,12 +108,7 @@ const styles = () => }, deviceInfo : { - marginTop : '0.4vmin', - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'flex-end', - '& span' : + '& span' : { userSelect : 'none', pointerEvents : 'none', @@ -159,6 +142,7 @@ class VideoView extends React.PureComponent { const { isMe, + isScreen, peer, displayName, showPeerInfo, @@ -181,59 +165,62 @@ class VideoView extends React.PureComponent return (
    - { advancedMode ? -
    -
    - { audioCodec ? -

    {audioCodec}

    - :null - } + - :null - } +
    { showPeerInfo ?
    - { isMe ? - onChangeDisplayName(newDisplayName)} - /> - : - - {displayName} - - } - - { advancedMode ? -
    - - {peer.device.name} {Math.floor(peer.device.version) || null} +
    + { isMe ? + onChangeDisplayName(newDisplayName)} + /> + : + + {displayName} -
    - :null - } + } + + { advancedMode ? +
    + + {peer.device.name} {Math.floor(peer.device.version) || null} + +
    + :null + } +
    :null } @@ -243,7 +230,7 @@ class VideoView extends React.PureComponent ref='video' className={classnames(classes.video, { hidden : !videoVisible, - 'is-me' : isMe, + 'isMe' : isMe && !isScreen, loading : videoProfile === 'none', contain : videoContain })} @@ -334,8 +321,9 @@ class VideoView extends React.PureComponent VideoView.propTypes = { - isMe : PropTypes.bool, - peer : PropTypes.oneOfType( + isMe : PropTypes.bool, + isScreen : PropTypes.bool, + peer : PropTypes.oneOfType( [ appPropTypes.Me, appPropTypes.Peer ]), displayName : PropTypes.string, showPeerInfo : PropTypes.bool, diff --git a/app/src/components/VideoWindow/NewWindow.js b/app/src/components/VideoWindow/NewWindow.js index 2ae370b..b2255f4 100644 --- a/app/src/components/VideoWindow/NewWindow.js +++ b/app/src/components/VideoWindow/NewWindow.js @@ -6,7 +6,7 @@ import FullScreen from '../FullScreen'; import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; -const styles = () => +const styles = (theme) => ({ root : { @@ -27,7 +27,7 @@ const styles = () => flexDirection : 'row', justifyContent : 'flex-start', alignItems : 'center', - padding : '0.4vmin' + padding : theme.spacing(1) }, button : { diff --git a/app/src/components/appPropTypes.js b/app/src/components/appPropTypes.js index 3349fd4..8473204 100644 --- a/app/src/components/appPropTypes.js +++ b/app/src/components/appPropTypes.js @@ -5,7 +5,7 @@ export const Room = PropTypes.shape( url : PropTypes.string.isRequired, state : PropTypes.oneOf( [ 'new', 'connecting', 'connected', 'closed' ]).isRequired, - activeSpeakerName : PropTypes.string + activeSpeakerId : PropTypes.string }); export const Device = PropTypes.shape( @@ -17,7 +17,7 @@ export const Device = PropTypes.shape( export const Me = PropTypes.shape( { - name : PropTypes.string.isRequired, + id : PropTypes.string.isRequired, device : Device.isRequired, canSendMic : PropTypes.bool.isRequired, canSendWebcam : PropTypes.bool.isRequired, @@ -26,30 +26,28 @@ export const Me = PropTypes.shape( export const Producer = PropTypes.shape( { - id : PropTypes.number.isRequired, - source : PropTypes.oneOf([ 'mic', 'webcam', 'screen' ]).isRequired, - deviceLabel : PropTypes.string, - type : PropTypes.oneOf([ 'front', 'back', 'screen' ]), - locallyPaused : PropTypes.bool.isRequired, - remotelyPaused : PropTypes.bool.isRequired, - track : PropTypes.any, - codec : PropTypes.string.isRequired + id : PropTypes.string.isRequired, + source : PropTypes.oneOf([ 'mic', 'webcam', 'screen' ]).isRequired, + deviceLabel : PropTypes.string, + type : PropTypes.oneOf([ 'front', 'back', 'screen' ]), + paused : PropTypes.bool.isRequired, + track : PropTypes.any, + codec : PropTypes.string.isRequired }); export const Peer = PropTypes.shape( { - name : PropTypes.string.isRequired, + id : PropTypes.string.isRequired, displayName : PropTypes.string, device : Device.isRequired, - consumers : PropTypes.arrayOf(PropTypes.number).isRequired + consumers : PropTypes.arrayOf(PropTypes.string).isRequired }); export const Consumer = PropTypes.shape( { - id : PropTypes.number.isRequired, - peerName : PropTypes.string.isRequired, + id : PropTypes.string.isRequired, + peerId : PropTypes.string.isRequired, source : PropTypes.oneOf([ 'mic', 'webcam', 'screen' ]).isRequired, - supported : PropTypes.bool.isRequired, locallyPaused : PropTypes.bool.isRequired, remotelyPaused : PropTypes.bool.isRequired, profile : PropTypes.oneOf([ 'none', 'default', 'low', 'medium', 'high' ]), @@ -75,7 +73,7 @@ export const Message = PropTypes.shape( export const FileEntryProps = PropTypes.shape( { data : PropTypes.shape({ - name : PropTypes.string.isRequired, + id : PropTypes.string.isRequired, picture : PropTypes.string, file : PropTypes.shape({ magnet : PropTypes.string.isRequired diff --git a/app/src/deviceInfo.js b/app/src/deviceInfo.js new file mode 100644 index 0000000..3e5d361 --- /dev/null +++ b/app/src/deviceInfo.js @@ -0,0 +1,31 @@ +import bowser from 'bowser'; + +window.BB = bowser; + +export default function() +{ + const ua = navigator.userAgent; + const browser = bowser.getParser(ua); + + let flag; + + if (browser.satisfies({ chrome: '>=0', chromium: '>=0' })) + flag = 'chrome'; + else if (browser.satisfies({ firefox: '>=0' })) + flag = 'firefox'; + else if (browser.satisfies({ safari: '>=0' })) + flag = 'safari'; + else if (browser.satisfies({ opera: '>=0' })) + flag = 'opera'; + else if (browser.satisfies({ 'microsoft edge': '>=0' })) + flag = 'edge'; + else + flag = 'unknown'; + + return { + flag, + name : browser.getBrowserName(), + version : browser.getBrowserVersion(), + bowser : browser + }; +} diff --git a/app/src/index.js b/app/src/index.js index d82f1d6..8e30c9d 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -1,14 +1,13 @@ import domready from 'domready'; -import UrlParse from 'url-parse'; import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; -import { getDeviceInfo } from 'mediasoup-client'; import randomString from 'random-string'; import Logger from './Logger'; import debug from 'debug'; import RoomClient from './RoomClient'; import RoomContext from './RoomContext'; +import deviceInfo from './deviceInfo'; import * as stateActions from './actions/stateActions'; import Room from './components/Room'; import LoadingView from './components/LoadingView'; @@ -44,57 +43,48 @@ function run() { logger.debug('run() [environment:%s]', process.env.NODE_ENV); - const peerName = randomString({ length: 8 }).toLowerCase(); - const urlParser = new UrlParse(window.location.href, true); + const peerId = randomString({ length: 8 }).toLowerCase(); + const urlParser = new URL(window.location); + const parameters = urlParser.searchParams; - let roomId = (urlParser.pathname).substr(1) - ? (urlParser.pathname).substr(1).toLowerCase() : urlParser.query.roomId.toLowerCase(); - const produce = urlParser.query.produce !== 'false'; - const useSimulcast = urlParser.query.simulcast === 'true'; + let roomId = (urlParser.pathname).substr(1); if (!roomId) + roomId = parameters.get('roomId'); + + if (roomId) + roomId = roomId.toLowerCase(); + else { roomId = randomString({ length: 8 }).toLowerCase(); - urlParser.query.roomId = roomId; + parameters.set('roomId', roomId); window.history.pushState('', '', urlParser.toString()); } - // Get the effective/shareable Room URL. - const roomUrlParser = new UrlParse(window.location.href, true); + const produce = parameters.get('produce') !== 'false'; + const consume = parameters.get('consume') !== 'false'; + const useSimulcast = parameters.get('simulcast') === 'true'; + const forceTcp = parameters.get('forceTcp') === 'true'; - for (const key of Object.keys(roomUrlParser.query)) - { - // Don't keep some custom params. - switch (key) - { - case 'roomId': - case 'simulcast': - break; - default: - delete roomUrlParser.query[key]; - } - } - delete roomUrlParser.hash; - - const roomUrl = roomUrlParser.toString(); + const roomUrl = window.location.href.split('?')[0]; // Get current device. - const device = getDeviceInfo(); + const device = deviceInfo(); store.dispatch( stateActions.setRoomUrl(roomUrl)); store.dispatch( stateActions.setMe({ - peerName, + peerId, device, loginEnabled : window.config.loginEnabled }) ); roomClient = new RoomClient( - { roomId, peerName, device, useSimulcast, produce }); + { roomId, peerId, device, useSimulcast, produce, consume, forceTcp }); global.CLIENT = roomClient; diff --git a/app/src/reducers/consumers.js b/app/src/reducers/consumers.js index 758efd2..2c99416 100644 --- a/app/src/reducers/consumers.js +++ b/app/src/reducers/consumers.js @@ -51,11 +51,30 @@ const consumers = (state = initialState, action) => return { ...state, [consumerId]: newConsumer }; } - case 'SET_CONSUMER_EFFECTIVE_PROFILE': + case 'SET_CONSUMER_CURRENT_LAYERS': { - const { consumerId, profile } = action.payload; + const { consumerId, spatialLayer, temporalLayer } = action.payload; const consumer = state[consumerId]; - const newConsumer = { ...consumer, profile }; + const newConsumer = + { + ...consumer, + currentSpatialLayer : spatialLayer, + currentTemporalLayer : temporalLayer + }; + + return { ...state, [consumerId]: newConsumer }; + } + + case 'SET_CONSUMER_PREFERRED_LAYERS': + { + const { consumerId, spatialLayer, temporalLayer } = action.payload; + const consumer = state[consumerId]; + const newConsumer = + { + ...consumer, + preferredSpatialLayer : spatialLayer, + preferredTemporalLayer : temporalLayer + }; return { ...state, [consumerId]: newConsumer }; } @@ -69,6 +88,19 @@ const consumers = (state = initialState, action) => return { ...state, [consumerId]: newConsumer }; } + case 'SET_CONSUMER_SCORE': + { + const { consumerId, score } = action.payload; + const consumer = state[consumerId]; + + if (!consumer) + return state; + + const newConsumer = { ...consumer, score }; + + return { ...state, [consumerId]: newConsumer }; + } + default: return state; } diff --git a/app/src/reducers/files.js b/app/src/reducers/files.js index e43c088..1154b09 100644 --- a/app/src/reducers/files.js +++ b/app/src/reducers/files.js @@ -4,17 +4,17 @@ const files = (state = {}, action) => { case 'ADD_FILE': { - const { file } = action.payload; + const { peerId, magnetUri } = action.payload; const newFile = { - active : false, - progress : 0, - files : null, - me : false, - ...file + active : false, + progress : 0, + files : null, + peerId : peerId, + magnetUri : magnetUri }; - return { ...state, [file.magnetUri]: newFile }; + return { ...state, [magnetUri]: newFile }; } case 'ADD_FILE_HISTORY': @@ -30,7 +30,6 @@ const files = (state = {}, action) => active : false, progress : 0, files : null, - me : false, ...file }; diff --git a/app/src/reducers/me.js b/app/src/reducers/me.js index 4750f0c..8f0969b 100644 --- a/app/src/reducers/me.js +++ b/app/src/reducers/me.js @@ -1,11 +1,11 @@ const initialState = { - name : null, + id : null, device : null, canSendMic : false, canSendWebcam : false, canShareScreen : false, - needExtension : false, + canShareFiles : false, audioDevices : null, webcamDevices : null, webcamInProgress : false, @@ -24,14 +24,14 @@ const me = (state = initialState, action) => case 'SET_ME': { const { - peerName, + peerId, device, loginEnabled } = action.payload; return { ...state, - name : peerName, + id : peerId, device, loginEnabled }; @@ -45,16 +45,20 @@ const me = (state = initialState, action) => case 'SET_MEDIA_CAPABILITIES': { - const { canSendMic, canSendWebcam } = action.payload; + const { + canSendMic, + canSendWebcam, + canShareScreen, + canShareFiles + } = action.payload; - return { ...state, canSendMic, canSendWebcam }; - } - - case 'SET_SCREEN_CAPABILITIES': - { - const { canShareScreen, needExtension } = action.payload; - - return { ...state, canShareScreen, needExtension }; + return { + ...state, + canSendMic, + canSendWebcam, + canShareScreen, + canShareFiles + }; } case 'SET_AUDIO_DEVICES': diff --git a/app/src/reducers/peerVolumes.js b/app/src/reducers/peerVolumes.js index 6b89db8..fafe739 100644 --- a/app/src/reducers/peerVolumes.js +++ b/app/src/reducers/peerVolumes.js @@ -7,33 +7,33 @@ const peerVolumes = (state = initialState, action) => case 'SET_ME': { const { - peerName + peerId } = action.payload; - return { ...state, [peerName]: 0 }; + return { ...state, [peerId]: 0 }; } case 'ADD_PEER': { const { peer } = action.payload; - return { ...state, [peer.name]: 0 }; + return { ...state, [peer.id]: 0 }; } case 'REMOVE_PEER': { - const { peerName } = action.payload; + const { peerId } = action.payload; const newState = { ...state }; - delete newState[peerName]; + delete newState[peerId]; return newState; } case 'SET_PEER_VOLUME': { - const { peerName, volume } = action.payload; + const { peerId, volume } = action.payload; - return { ...state, [peerName]: volume }; + return { ...state, [peerId]: volume }; } default: diff --git a/app/src/reducers/peers.js b/app/src/reducers/peers.js index ea9cb44..ddd104f 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -1,5 +1,3 @@ -import omit from 'lodash/omit'; - const peer = (state = {}, action) => { switch (action.type) @@ -53,12 +51,17 @@ const peers = (state = {}, action) => { case 'ADD_PEER': { - return { ...state, [action.payload.peer.name]: peer(undefined, action) }; + return { ...state, [action.payload.peer.id]: peer(undefined, action) }; } case 'REMOVE_PEER': { - return omit(state, [ action.payload.peerName ]); + const { peerId } = action.payload; + const newState = { ...state }; + + delete newState[peerId]; + + return newState; } case 'SET_PEER_DISPLAY_NAME': @@ -69,25 +72,25 @@ const peers = (state = {}, action) => case 'SET_PEER_PICTURE': case 'ADD_CONSUMER': { - const oldPeer = state[action.payload.peerName]; + const oldPeer = state[action.payload.peerId]; if (!oldPeer) { throw new Error('no Peer found'); } - return { ...state, [oldPeer.name]: peer(oldPeer, action) }; + return { ...state, [oldPeer.id]: peer(oldPeer, action) }; } case 'REMOVE_CONSUMER': { - const oldPeer = state[action.payload.peerName]; + const oldPeer = state[action.payload.peerId]; // NOTE: This means that the Peer was closed before, so it's ok. if (!oldPeer) return state; - return { ...state, [oldPeer.name]: peer(oldPeer, action) }; + return { ...state, [oldPeer.id]: peer(oldPeer, action) }; } default: diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index cdf9c0a..e1444c3 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -4,17 +4,17 @@ const initialState = state : 'new', // new/connecting/connected/disconnected/closed, locked : false, lockedOut : false, - audioSuspended : false, - activeSpeakerName : null, + activeSpeakerId : null, torrentSupport : false, showSettings : false, fullScreenConsumer : null, // ConsumerID windowConsumer : null, // ConsumerID toolbarsVisible : true, mode : 'democratic', - selectedPeerName : null, + selectedPeerId : null, spotlights : [], - settingsOpen : false + settingsOpen : false, + joined : false }; const room = (state = initialState, action) => @@ -35,7 +35,7 @@ const room = (state = initialState, action) => if (roomState === 'connected') return { ...state, state: roomState }; else - return { ...state, state: roomState, activeSpeakerName: null }; + return { ...state, state: roomState, activeSpeakerId: null }; } case 'SET_ROOM_LOCKED': @@ -53,13 +53,6 @@ const room = (state = initialState, action) => return { ...state, lockedOut: true }; } - case 'SET_AUDIO_SUSPENDED': - { - const { audioSuspended } = action.payload; - - return { ...state, audioSuspended }; - } - case 'SET_SETTINGS_OPEN': { const { settingsOpen } = action.payload; @@ -69,9 +62,9 @@ const room = (state = initialState, action) => case 'SET_ROOM_ACTIVE_SPEAKER': { - const { peerName } = action.payload; + const { peerId } = action.payload; - return { ...state, activeSpeakerName: peerName }; + return { ...state, activeSpeakerId: peerId }; } case 'FILE_SHARING_SUPPORTED': @@ -88,6 +81,13 @@ const room = (state = initialState, action) => return { ...state, showSettings }; } + case 'TOGGLE_JOINED': + { + const joined = !state.joined; + + return { ...state, joined }; + } + case 'TOGGLE_FULLSCREEN_CONSUMER': { const { consumerId } = action.payload; @@ -119,13 +119,13 @@ const room = (state = initialState, action) => case 'SET_SELECTED_PEER': { - const { selectedPeerName } = action.payload; + const { selectedPeerId } = action.payload; return { ...state, - selectedPeerName : state.selectedPeerName === selectedPeerName ? - null : selectedPeerName + selectedPeerId : state.selectedPeerId === selectedPeerId ? + null : selectedPeerId }; } diff --git a/app/src/urlFactory.js b/app/src/urlFactory.js index fc4a9f5..7c4dbcd 100644 --- a/app/src/urlFactory.js +++ b/app/src/urlFactory.js @@ -1,10 +1,10 @@ -export function getSignalingUrl(peerName, roomId) +export function getSignalingUrl(peerId, roomId) { const hostname = window.location.hostname; const port = process.env.NODE_ENV !== 'production' ? window.config.developmentPort : window.location.port; - const url = `wss://${hostname}:${port}/?peerName=${peerName}&roomId=${roomId}`; + const url = `wss://${hostname}:${port}/?peerId=${peerId}&roomId=${roomId}`; return url; } diff --git a/server/config/config.example.js b/server/config/config.example.js index 9b2fab1..59c4252 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -1,15 +1,29 @@ +const os = require('os'); + module.exports = { // oAuth2 conf - oauth2 : + auth : { - clientID : '', - clientSecret : '', - callbackURL : 'https://mYDomainName:port/auth-callback' + /* + The issuer URL for OpenID Connect discovery + The OpenID Provider Configuration Document + could be discovered on: + issuerURL + '/.well-known/openid-configuration' + */ + issuerURL : 'https://example.com', + clientOptions : + { + client_id : '', + client_secret : '', + scope : 'openid email profile', + // where client.example.com is your multiparty meeting server + redirect_uri : 'https://client.example.com/auth/callback' + } }, - // Listening hostname for `gulp live|open`. - domain : 'localhost', - tls : + // session cookie secret + cookieSecret : 'T0P-S3cR3t_cook!e', + tls : { cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`, key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem` @@ -19,59 +33,61 @@ module.exports = // Any http request is redirected to https. // Listening port for http server. listeningRedirectPort : 80, - // STUN/TURN + // Mediasoup settings mediasoup : { - // mediasoup Server settings. - logLevel : 'warn', - logTags : - [ - 'info', - 'ice', - 'dtls', - 'rtp', - 'srtp', - 'rtcp', - 'rbe', - 'rtx' - ], - rtcIPv4 : true, - rtcIPv6 : true, - rtcAnnouncedIPv4 : null, - rtcAnnouncedIPv6 : null, - rtcMinPort : 40000, - rtcMaxPort : 49999, - // mediasoup Room codecs. - mediaCodecs : - [ - { - kind : 'audio', - name : 'opus', - clockRate : 48000, - channels : 2, - parameters : + numWorkers : Object.keys(os.cpus()).length, + // mediasoup Worker settings. + worker : + { + logLevel : 'warn', + logTags : + [ + 'info', + 'ice', + 'dtls', + 'rtp', + 'srtp', + 'rtcp' + ], + rtcMinPort : 40000, + rtcMaxPort : 49999 + }, + // mediasoup Router settings. + router : + { + // Router media codecs. + mediaCodecs : + [ { - useinbandfec : 1 - } - }, - // { - // kind : 'video', - // name : 'VP8', - // clockRate : 90000 - // } - { - kind : 'video', - name : 'H264', - clockRate : 90000, - parameters : + kind : 'audio', + mimeType : 'audio/opus', + clockRate : 48000, + channels : 2 + }, { - 'packetization-mode' : 1, - 'profile-level-id' : '42e01f', - 'level-asymmetry-allowed' : 1 + kind : 'video', + mimeType : 'video/h264', + clockRate : 90000, + parameters : + { + 'packetization-mode' : 1, + 'profile-level-id' : '42e01f', + 'level-asymmetry-allowed' : 1, + 'x-google-start-bitrate' : 1000 + } } - } - ], - // mediasoup per Peer max sending bitrate (in bps). - maxBitrate : 500000 + ] + }, + // mediasoup WebRtcTransport settings. + webRtcTransport : + { + listenIps : + [ + { ip: '1.2.3.4', announcedIp: null } + ], + maxIncomingBitrate : 1500000, + initialAvailableOutgoingBitrate : 1000000 + } } }; diff --git a/server/lib/Room.js b/server/lib/Room.js index 741325a..8c7f8d6 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -4,15 +4,41 @@ const EventEmitter = require('events').EventEmitter; const Logger = require('./Logger'); const config = require('../config/config'); -const MAX_BITRATE = config.mediasoup.maxBitrate || 1000000; -const MIN_BITRATE = Math.min(50000, MAX_BITRATE); -const BITRATE_FACTOR = 0.75; - const logger = new Logger('Room'); class Room extends EventEmitter { - constructor(roomId, mediaServer, io) + /** + * Factory function that creates and returns Room instance. + * + * @async + * + * @param {mediasoup.Worker} mediasoupWorker - The mediasoup Worker in which a new + * mediasoup Router must be created. + * @param {String} roomId - Id of the Room instance. + */ + static async create({ mediasoupWorker, roomId }) + { + logger.info('create() [roomId:%s, forceH264:%s]', roomId); + + // Router media codecs. + let mediaCodecs = config.mediasoup.router.mediaCodecs; + + // Create a mediasoup Router. + const mediasoupRouter = await mediasoupWorker.createRouter({ mediaCodecs }); + + // Create a mediasoup AudioLevelObserver. + const audioLevelObserver = await mediasoupRouter.createAudioLevelObserver( + { + maxEntries : 1, + threshold : -80, + interval : 800 + }); + + return new Room({ roomId, mediasoupRouter, audioLevelObserver }); + } + + constructor({ roomId, mediasoupRouter, audioLevelObserver }) { logger.info('constructor() [roomId:"%s"]', roomId); @@ -34,30 +60,51 @@ class Room extends EventEmitter this._lastN = []; - this._io = io; + // this._io = io; - this._signalingPeers = new Map(); + this._peers = new Map(); - try + // mediasoup Router instance. + // @type {mediasoup.Router} + this._mediasoupRouter = mediasoupRouter; + + // mediasoup AudioLevelObserver. + // @type {mediasoup.AudioLevelObserver} + this._audioLevelObserver = audioLevelObserver; + + // Set audioLevelObserver events. + this._audioLevelObserver.on('volumes', (volumes) => { - // mediasoup Room instance. - this._mediaRoom = mediaServer.Room(config.mediasoup.mediaCodecs); - } - catch (error) + const { producer, volume } = volumes[0]; + + // logger.debug( + // 'audioLevelObserver "volumes" event [producerId:%s, volume:%s]', + // producer.id, volume); + + // Notify all Peers. + this._peers.forEach((peer) => + { + this._notification(peer.socket, 'activeSpeaker', { + peerId : producer.appData.peerId, + volume : volume + }); + }); + }); + + this._audioLevelObserver.on('silence', () => { - this.close(); + // logger.debug('audioLevelObserver "silence" event'); - throw error; - } - - // Current max bitrate for all the participants. - this._maxBitrate = MAX_BITRATE; + // Notify all Peers. + this._peers.forEach((peer) => + { + this._notification(peer.socket, 'activeSpeaker', { peerId : null }); + }); + }); // Current active speaker. // @type {mediasoup.Peer} this._currentActiveSpeaker = null; - - this._handleMediaRoom(); } get id() @@ -71,19 +118,20 @@ class Room extends EventEmitter this._closed = true; - // Close the signalingPeers - if (this._signalingPeers) - for (let peer of this._signalingPeers) + // Close the peers + if (this._peers) + { + this._peers.forEach((peer) => { if (peer.socket) peer.socket.disconnect(); - }; + }); + } - this._signalingPeers.clear(); + this._peers.clear(); - // Close the mediasoup Room. - if (this._mediaRoom) - this._mediaRoom.close(); + // Close the mediasoup Router. + this._mediasoupRouter.close(); // Emit 'close' event. this.emit('close'); @@ -91,55 +139,52 @@ class Room extends EventEmitter logStatus() { - if (!this._mediaRoom) - return; - logger.info( - 'logStatus() [room id:"%s", peers:%s, mediasoup peers:%s]', + 'logStatus() [room id:"%s", peers:%s]', this._roomId, - this._signalingPeers.length, - this._mediaRoom.peers.length); + this._peers.size + ); } - handleConnection(peerName, socket) + handleConnection({ peerId, consume, socket }) { - logger.info('handleConnection() [peerName:"%s"]', peerName); + logger.info('handleConnection() [peerId:"%s"]', peerId); // This will allow reconnects to join despite lock - if (this._signalingPeers.has(peerName)) + if (this._peers.has(peerId)) { logger.warn( - 'handleConnection() | there is already a peer with same peerName, ' + - 'closing the previous one [peerName:"%s"]', - peerName); + 'handleConnection() | there is already a peer with same peerId, ' + + 'closing the previous one [peerId:"%s"]', + peerId); - const signalingPeer = this._signalingPeers.get(peerName); + const peer = this._peers.get(peerId); - signalingPeer.socket.disconnect(); - this._signalingPeers.delete(peerName); + peer.socket.disconnect(); + this._peers.delete(peerId); } else if (this._locked) // Don't allow connections to a locked room { - socket.emit('room-locked'); + this._notification(socket, 'roomLocked'); socket.disconnect(true); return; } socket.join(this._roomId); - const signalingPeer = { peerName : peerName, socket : socket }; + const peer = { id : peerId, socket : socket }; - const index = this._lastN.indexOf(peerName); + const index = this._lastN.indexOf(peerId); if (index === -1) // We don't have this peer, add to end { - this._lastN.push(peerName); + this._lastN.push(peerId); } - this._signalingPeers.set(peerName, signalingPeer); + this._peers.set(peerId, peer); - this._handleSignalingPeer(signalingPeer); - socket.emit('room-ready'); + this._handlePeer({ peer, consume }); + this._notification(socket, 'roomReady'); } isLocked() @@ -152,566 +197,869 @@ class Room extends EventEmitter logger.debug('authCallback()'); const { - peerName, - name, + peerId, + displayName, picture } = data; - const signalingPeer = this._signalingPeers.get(peerName); + const peer = this._peers.get(peerId); - if (signalingPeer) + if (peer) { - signalingPeer.socket.emit('auth', - { - name : name, - picture : picture + this._notification(peer.socket, 'auth', { + displayName : displayName, + picture : picture }); } } - _handleMediaRoom() + _handlePeer({ peer, consume }) { - logger.debug('_handleMediaRoom()'); + logger.debug('_handlePeer() [peer:"%s"]', peer.id); - const activeSpeakerDetector = this._mediaRoom.createActiveSpeakerDetector(); + peer.data = {}; - activeSpeakerDetector.on('activespeakerchange', (activePeer) => + // Not joined after a custom protoo 'join' request is later received. + peer.data.consume = consume; + peer.data.joined = false; + peer.data.displayName = undefined; + peer.data.device = undefined; + peer.data.rtpCapabilities = undefined; + peer.data.raiseHandState = false; + + // Have mediasoup related maps ready even before the Peer joins since we + // allow creating Transports before joining. + peer.data.transports = new Map(); + peer.data.producers = new Map(); + peer.data.consumers = new Map(); + + peer.socket.on('request', (request, cb) => { - if (activePeer) - { - logger.info('new active speaker [peerName:"%s"]', activePeer.name); + logger.debug( + 'Peer "request" event [method:%s, peerId:%s]', + request.method, peer.id); - this._currentActiveSpeaker = activePeer; - - const index = this._lastN.indexOf(activePeer.name); - - if (index > -1) // We have this speaker in the list, move to front + this._handleSocketRequest(peer, request, cb) + .catch((error) => { - this._lastN.splice(index, 1); - this._lastN = [activePeer.name].concat(this._lastN); - } + logger.error('request failed:%o', error); - const activeVideoProducer = activePeer.producers - .find((producer) => producer.kind === 'video'); - - for (const peer of this._mediaRoom.peers) - { - for (const consumer of peer.consumers) - { - if (consumer.kind !== 'video') - continue; - - if (consumer.source === activeVideoProducer) - { - consumer.setPreferredProfile('high'); - } - else - { - consumer.setPreferredProfile('low'); - } - } - } - } - else - { - logger.info('no active speaker'); - - this._currentActiveSpeaker = null; - - for (const peer of this._mediaRoom.peers) - { - for (const consumer of peer.consumers) - { - if (consumer.kind !== 'video') - continue; - - consumer.setPreferredProfile('low'); - } - } - } - - this._io.to(this._roomId).emit('active-speaker', { - peerName : activePeer ? activePeer.name : null + cb(error); }); }); - } - _handleSignalingPeer(signalingPeer) - { - logger.debug('_handleSignalingPeer() [peer:"%s"]', signalingPeer.id); - - signalingPeer.socket.on('mediasoup-request', (request, cb) => + peer.socket.on('disconnect', () => { - const mediasoupRequest = request; - - this._handleMediasoupClientRequest( - signalingPeer, mediasoupRequest, cb); - }); - - signalingPeer.socket.on('mediasoup-notification', (request, cb) => - { - // Return no error - cb(null); - - const mediasoupNotification = request; - - this._handleMediasoupClientNotification( - signalingPeer, mediasoupNotification); - }); - - signalingPeer.socket.on('change-display-name', (request, cb) => - { - // Return no error - cb(null); - - const { displayName } = request; - const mediaPeer = this._mediaRoom.getPeerByName(signalingPeer.peerName); - const oldDisplayName = mediaPeer.appData.displayName; - - mediaPeer.appData.displayName = displayName; - - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'display-name-changed', - { - peerName : signalingPeer.peerName, - displayName : displayName, - oldDisplayName : oldDisplayName - } - ); - }); - - signalingPeer.socket.on('change-profile-picture', (request, cb) => - { - // Return no error - cb(null); - - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'profile-picture-changed', - { - peerName : signalingPeer.peerName, - picture : request.picture - } - ); - }); - - signalingPeer.socket.on('chat-message', (request, cb) => - { - // Return no error - cb(null); - - const { chatMessage } = request; - - this._chatHistory.push(chatMessage); - - // Spread to others - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'chat-message-receive', - { - peerName : signalingPeer.peerName, - chatMessage : chatMessage - } - ); - }); - - signalingPeer.socket.on('server-history', (request, cb) => - { - // Return to sender - cb( - null, - { - chatHistory : this._chatHistory, - fileHistory : this._fileHistory, - lastN : this._lastN - } - ); - }); - - signalingPeer.socket.on('lock-room', (request, cb) => - { - // Return no error - cb(null); - - this._locked = true; - - // Spread to others - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'lock-room', - { - peerName : signalingPeer.peerName - } - ); - }); - - signalingPeer.socket.on('unlock-room', (request, cb) => - { - // Return no error - cb(null); - - this._locked = false; - - // Spread to others - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'unlock-room', - { - peerName : signalingPeer.peerName - } - ); - }); - - signalingPeer.socket.on('send-file', (request, cb) => - { - // Return no error - cb(null); - - const fileData = request.file; - - this._fileHistory.push(fileData); - - // Spread to others - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'file-receive', - { - peerName : signalingPeer.peerName, - file : fileData - } - ); - }); - - signalingPeer.socket.on('raisehand-message', (request, cb) => - { - // Return no error - cb(null); - - const { raiseHandState } = request; - const { mediaPeer } = signalingPeer; - - mediaPeer.appData.raiseHandState = raiseHandState; - // Spread to others - signalingPeer.socket.broadcast.to(this._roomId).emit( - 'raisehand-message', - { - peerName : signalingPeer.peerName, - raiseHandState : raiseHandState - }, - ); - }); - - signalingPeer.socket.on('request-consumer-keyframe', (request, cb) => - { - cb(null); - - const { consumerId } = request; - const mediaPeer = this._mediaRoom.getPeerByName(signalingPeer.peerName); - const consumer = mediaPeer.consumers - .find((_consumer) => _consumer.id === consumerId); - - if (!consumer) - { - logger.warn('consumer with id "%s" not found', consumerId); - + if (this._closed) return; + + logger.debug('Peer "close" event [peerId:%s]', peer.id); + + // If the Peer was joined, notify all Peers. + if (peer.data.joined) + { + this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true); } - - consumer.requestKeyFrame(); - }); - signalingPeer.socket.on('disconnect', () => - { - logger.debug('Peer "close" event [peer:"%s"]', signalingPeer.peerName); - - const mediaPeer = this._mediaRoom.getPeerByName(signalingPeer.peerName); - - if (mediaPeer && !mediaPeer.closed) - mediaPeer.close(); - - const index = this._lastN.indexOf(signalingPeer.peerName); + const index = this._lastN.indexOf(peer.id); if (index > -1) // We have this peer in the list, remove { this._lastN.splice(index, 1); } - // If this is the latest peer in the room, close the room. - // However wait a bit (for reconnections). - setTimeout(() => + // Iterate and close all mediasoup Transport associated to this Peer, so all + // its Producers and Consumers will also be closed. + for (const transport of peer.data.transports.values()) { - if (this._mediaRoom && this._mediaRoom.closed) - return; - - if (this._mediaRoom.peers.length === 0) - { - logger.info( - 'last peer in the room left, closing the room [roomId:"%s"]', - this._roomId); - - this.close(); - } - }, 5000); - }); - } - - _handleMediaPeer(signalingPeer, mediaPeer) - { - mediaPeer.on('notify', (notification) => - { - signalingPeer.socket.emit('mediasoup-notification', notification); - }); - - mediaPeer.on('newtransport', (transport) => - { - logger.info( - 'mediaPeer "newtransport" event [id:%s, direction:%s]', - transport.id, transport.direction); - - // Update peers max sending bitrate. - if (transport.direction === 'send') - { - this._updateMaxBitrate(); - - transport.on('close', () => - { - this._updateMaxBitrate(); - }); + transport.close(); } - this._handleMediaTransport(transport); - }); + this._peers.delete(peer.id); - mediaPeer.on('newproducer', (producer) => - { - logger.info('mediaPeer "newproducer" event [id:%s]', producer.id); - - this._handleMediaProducer(producer); - }); - - mediaPeer.on('newconsumer', (consumer) => - { - logger.info('mediaPeer "newconsumer" event [id:%s]', consumer.id); - - this._handleMediaConsumer(consumer); - }); - - // Also handle already existing Consumers. - for (const consumer of mediaPeer.consumers) - { - logger.info('mediaPeer existing "consumer" [id:%s]', consumer.id); - - this._handleMediaConsumer(consumer); - } - - // Notify about the existing active speaker. - if (this._currentActiveSpeaker) - { - signalingPeer.socket.emit( - 'active-speaker', + // If this is the latest Peer in the room, close the room after a while. + if (this._peers.size === 0) + { + setTimeout(() => { - peerName : this._currentActiveSpeaker.name - }); - } - } + if (this._closed) + return; - _handleMediaTransport(transport) - { - transport.on('close', (originator) => - { - logger.info( - 'Transport "close" event [originator:%s]', originator); + if (this._peers.size === 0) + { + logger.info( + 'last Peer in the room left, closing the room [roomId:%s]', + this._roomId); + + this.close(); + } + }, 10000); + } }); } - _handleMediaProducer(producer) + async _handleSocketRequest(peer, request, cb) { - producer.on('close', (originator) => - { - logger.info( - 'Producer "close" event [originator:%s]', originator); - }); - - producer.on('pause', (originator) => - { - logger.info( - 'Producer "pause" event [originator:%s]', originator); - }); - - producer.on('resume', (originator) => - { - logger.info( - 'Producer "resume" event [originator:%s]', originator); - }); - } - - _handleMediaConsumer(consumer) - { - consumer.on('close', (originator) => - { - logger.info( - 'Consumer "close" event [originator:%s]', originator); - }); - - consumer.on('pause', (originator) => - { - logger.info( - 'Consumer "pause" event [originator:%s]', originator); - }); - - consumer.on('resume', (originator) => - { - logger.info( - 'Consumer "resume" event [originator:%s]', originator); - }); - - consumer.on('effectiveprofilechange', (profile) => - { - logger.info( - 'Consumer "effectiveprofilechange" event [profile:%s]', profile); - }); - - // If video, initially make it 'low' profile unless this is for the current - // active speaker. - if (consumer.kind === 'video' && consumer.peer !== this._currentActiveSpeaker) - consumer.setPreferredProfile('low'); - } - - _handleMediasoupClientRequest(signalingPeer, request, cb) - { - logger.debug( - 'mediasoup-client request [method:%s, peer:"%s"]', - request.method, signalingPeer.peerName); - switch (request.method) { - case 'queryRoom': + + case 'getRouterRtpCapabilities': { - this._mediaRoom.receiveRequest(request) - .then((response) => cb(null, response)) - .catch((error) => cb(error.toString())); + cb(null, this._mediasoupRouter.rtpCapabilities); break; } case 'join': { - // TODO: Handle appData. Yes? - const { peerName } = request; + // Ensure the Peer is not already joined. + if (peer.data.joined) + throw new Error('Peer already joined'); - if (peerName !== signalingPeer.peerName) + const { + displayName, + picture, + device, + rtpCapabilities + } = request.data; + + // Store client data into the protoo Peer data object. + peer.data.displayName = displayName; + peer.data.picture = picture; + peer.data.device = device; + peer.data.rtpCapabilities = rtpCapabilities; + + // Tell the new Peer about already joined Peers. + // And also create Consumers for existing Producers. + + const peerInfos = []; + + this._peers.forEach((joinedPeer) => { - cb('that is not your corresponding mediasoup Peer name'); - - break; - } - else if (signalingPeer.mediaPeer) - { - cb('already have a mediasoup Peer'); - - break; - } - - this._mediaRoom.receiveRequest(request) - .then((response) => + if (joinedPeer.data.joined) { - cb(null, response); + peerInfos.push( + { + id : joinedPeer.id, + displayName : joinedPeer.data.displayName, + picture : joinedPeer.data.picture, + device : joinedPeer.data.device + }); + + for (const producer of joinedPeer.data.producers.values()) + { + this._createConsumer( + { + consumerPeer : peer, + producerPeer : joinedPeer, + producer + }); + } + } + }); - // Get the newly created mediasoup Peer. - const mediaPeer = this._mediaRoom.getPeerByName(peerName); + cb(null, { peers: peerInfos }); - signalingPeer.mediaPeer = mediaPeer; + // Mark the new Peer as joined. + peer.data.joined = true; - this._handleMediaPeer(signalingPeer, mediaPeer); - }) - .catch((error) => + this._notification( + peer.socket, + 'newPeer', { - cb(error.toString()); + id : peer.id, + displayName : displayName, + picture : picture, + device : device + }, + true + ); + + logger.debug( + 'peer joined [peeerId: %s, displayName: %s, picture: %s, device: %o]', + peer.id, displayName, picture, device); + + break; + } + + case 'createWebRtcTransport': + { + // NOTE: Don't require that the Peer is joined here, so the client can + // initiate mediasoup Transports and be ready when he later joins. + + const { forceTcp, producing, consuming } = request.data; + const { + maxIncomingBitrate, + initialAvailableOutgoingBitrate + } = config.mediasoup.webRtcTransport; + + const transport = await this._mediasoupRouter.createWebRtcTransport( + { + listenIps : config.mediasoup.webRtcTransport.listenIps, + enableUdp : !forceTcp, + enableTcp : true, + preferUdp : true, + initialAvailableOutgoingBitrate, + appData : { producing, consuming } }); + // Store the WebRtcTransport into the protoo Peer data Object. + peer.data.transports.set(transport.id, transport); + + cb( + null, + { + id : transport.id, + iceParameters : transport.iceParameters, + iceCandidates : transport.iceCandidates, + dtlsParameters : transport.dtlsParameters + }); + + // If set, apply max incoming bitrate limit. + if (maxIncomingBitrate) + { + try { await transport.setMaxIncomingBitrate(maxIncomingBitrate); } + catch (error) {} + } + + break; + } + + case 'connectWebRtcTransport': + { + const { transportId, dtlsParameters } = request.data; + const transport = peer.data.transports.get(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + await transport.connect({ dtlsParameters }); + + cb(); + + break; + } + + case 'restartIce': + { + const { transportId } = request.data; + const transport = peer.data.transports.get(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + const iceParameters = await transport.restartIce(); + + cb(null, iceParameters); + + break; + } + + case 'produce': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { transportId, kind, rtpParameters } = request.data; + let { appData } = request.data; + const transport = peer.data.transports.get(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + // Add peerId into appData to later get the associated Peer during + // the 'loudest' event of the audioLevelObserver. + appData = { ...appData, peerId: peer.id }; + + const producer = + await transport.produce({ kind, rtpParameters, appData }); + + // Store the Producer into the protoo Peer data Object. + peer.data.producers.set(producer.id, producer); + + // Set Producer events. + producer.on('score', (score) => + { + // logger.debug( + // 'producer "score" event [producerId:%s, score:%o]', + // producer.id, score); + + this._notification(peer.socket, 'producerScore', { producerId: producer.id, score }); + }); + + producer.on('videoorientationchange', (videoOrientation) => + { + logger.debug( + 'producer "videoorientationchange" event [producerId:%s, videoOrientation:%o]', + producer.id, videoOrientation); + }); + + cb(null, { id: producer.id }); + + this._peers.forEach((otherPeer) => + { + if (otherPeer.data.joined && otherPeer !== peer) + { + this._createConsumer( + { + consumerPeer : otherPeer, + producerPeer : peer, + producer + }); + } + }); + + // Add into the audioLevelObserver. + if (kind === 'audio') + { + this._audioLevelObserver.addProducer({ producerId: producer.id }) + .catch(() => {}); + } + + break; + } + + case 'closeProducer': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { producerId } = request.data; + const producer = peer.data.producers.get(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + producer.close(); + + // Remove from its map. + peer.data.producers.delete(producer.id); + + cb(); + + break; + } + + case 'pauseProducer': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { producerId } = request.data; + const producer = peer.data.producers.get(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + await producer.pause(); + + cb(); + + break; + } + + case 'resumeProducer': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { producerId } = request.data; + const producer = peer.data.producers.get(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + await producer.resume(); + + cb(); + + break; + } + + case 'pauseConsumer': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { consumerId } = request.data; + const consumer = peer.data.consumers.get(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.pause(); + + cb(); + + break; + } + + case 'resumeConsumer': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { consumerId } = request.data; + const consumer = peer.data.consumers.get(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.resume(); + + cb(); + + break; + } + + case 'setConsumerPreferedLayers': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { consumerId, spatialLayer, temporalLayer } = request.data; + const consumer = peer.data.consumers.get(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.setPreferredLayers({ spatialLayer, temporalLayer }); + + cb(); + + break; + } + + case 'requestConsumerKeyFrame': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { consumerId } = request.data; + const consumer = peer.data.consumers.get(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.requestKeyFrame(); + + cb(); + + break; + } + + case 'getTransportStats': + { + const { transportId } = request.data; + const transport = peer.data.transports.get(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + const stats = await transport.getStats(); + + cb(null, stats); + + break; + } + + case 'getProducerStats': + { + const { producerId } = request.data; + const producer = peer.data.producers.get(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + const stats = await producer.getStats(); + + cb(null, stats); + + break; + } + + case 'getConsumerStats': + { + const { consumerId } = request.data; + const consumer = peer.data.consumers.get(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + const stats = await consumer.getStats(); + + cb(null, stats); + + break; + } + + case 'changeDisplayName': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { displayName } = request.data; + const oldDisplayName = peer.data.displayName; + + peer.data.displayName = displayName; + + // Spread to others + this._notification(peer.socket, 'changeDisplayName', { + peerId : peer.id, + displayName : displayName, + oldDisplayName : oldDisplayName + }, true); + + // Return no error + cb(); + + break; + } + + case 'changeProfilePicture': + { + // Ensure the Peer is joined. + if (!peer.data.joined) + throw new Error('Peer not yet joined'); + + const { picture } = request.data; + + // Spread to others + this._notification(peer.socket, 'changeProfilePicture', { + peerId : peer.id, + picture : picture + }, true); + + // Return no error + cb(); + + break; + } + + case 'chatMessage': + { + const { chatMessage } = request.data; + + this._chatHistory.push(chatMessage); + + // Spread to others + this._notification(peer.socket, 'chatMessage', { + peerId : peer.id, + chatMessage : chatMessage + }, true); + + // Return no error + cb(); + + break; + } + + case 'serverHistory': + { + // Return to sender + cb( + null, + { + chatHistory : this._chatHistory, + fileHistory : this._fileHistory, + lastN : this._lastN + } + ); + + break; + } + + case 'lockRoom': + { + this._locked = true; + + // Spread to others + this._notification(peer.socket, 'lockRoom', { + peerId : peer.id + }, true); + + // Return no error + cb(); + + break; + } + + case 'unlockRoom': + { + this._locked = false; + + // Spread to others + this._notification(peer.socket, 'unlockRoom', { + peerId : peer.id + }, true); + + // Return no error + cb(); + + break; + } + + case 'sendFile': + { + const { magnetUri } = request.data; + + this._fileHistory.push({ peerId: peer.id, magnetUri: magnetUri }); + + // Spread to others + this._notification(peer.socket, 'sendFile', { + peerId : peer.id, + magnetUri : magnetUri + }, true); + + // Return no error + cb(); + + break; + } + + case 'raiseHand': + { + const { raiseHandState } = request.data; + + peer.data.raiseHandState = raiseHandState; + + // Spread to others + this._notification(peer.socket, 'raiseHand', { + peerId : peer.id, + raiseHandState : raiseHandState + }, true); + + // Return no error + cb(); + break; } default: { - const { mediaPeer } = signalingPeer; + logger.error('unknown request.method "%s"', request.method); - if (!mediaPeer) - { - logger.error( - 'cannot handle mediasoup request, no mediasoup Peer [method:"%s"]', - request.method); - - cb('no mediasoup Peer'); - } - - mediaPeer.receiveRequest(request) - .then((response) => cb(null, response)) - .catch((error) => cb(error.toString())); + cb(500, `unknown request.method "${request.method}"`); } } } - _handleMediasoupClientNotification(signalingPeer, notification) + /** + * Creates a mediasoup Consumer for the given mediasoup Producer. + * + * @async + */ + async _createConsumer({ consumerPeer, producerPeer, producer }) { - logger.debug( - 'mediasoup-client notification [method:%s, peer:"%s"]', - notification.method, signalingPeer.peerName); + // Optimization: + // - Create the server-side Consumer. If video, do it paused. + // - Tell its Peer about it and wait for its response. + // - Upon receipt of the response, resume the server-side Consumer. + // - If video, this will mean a single key frame requested by the + // server-side Consumer (when resuming it). - // NOTE: mediasoup-client just sends notifications with target 'peer', - // so first of all, get the mediasoup Peer. - const { mediaPeer } = signalingPeer; - - if (!mediaPeer) + // NOTE: Don't create the Consumer if the remote Peer cannot consume it. + if ( + !consumerPeer.data.rtpCapabilities || + !this._mediasoupRouter.canConsume( + { + producerId : producer.id, + rtpCapabilities : consumerPeer.data.rtpCapabilities + }) + ) { - logger.error( - 'cannot handle mediasoup notification, no mediasoup Peer [method:"%s"]', - notification.method); + return; + } + + // Must take the Transport the remote Peer is using for consuming. + const transport = Array.from(consumerPeer.data.transports.values()) + .find((t) => t.appData.consuming); + + // This should not happen. + if (!transport) + { + logger.warn('_createConsumer() | Transport for consuming not found'); return; } - mediaPeer.receiveNotification(notification); + // Create the Consumer in paused mode. + let consumer; + + try + { + consumer = await transport.consume( + { + producerId : producer.id, + rtpCapabilities : consumerPeer.data.rtpCapabilities, + paused : producer.kind === 'video' + }); + } + catch (error) + { + logger.warn('_createConsumer() | transport.consume():%o', error); + + return; + } + + // Store the Consumer into the protoo consumerPeer data Object. + consumerPeer.data.consumers.set(consumer.id, consumer); + + // Set Consumer events. + consumer.on('transportclose', () => + { + // Remove from its map. + consumerPeer.data.consumers.delete(consumer.id); + }); + + consumer.on('producerclose', () => + { + // Remove from its map. + consumerPeer.data.consumers.delete(consumer.id); + + this._notification(consumerPeer.socket, 'consumerClosed', { consumerId: consumer.id }); + }); + + consumer.on('producerpause', () => + { + this._notification(consumerPeer.socket, 'consumerPaused', { consumerId: consumer.id }); + }); + + consumer.on('producerresume', () => + { + this._notification(consumerPeer.socket, 'consumerResumed', { consumerId: consumer.id }); + }); + + consumer.on('score', (score) => + { + // logger.debug( + // 'consumer "score" event [consumerId:%s, score:%o]', + // consumer.id, score); + + this._notification(consumerPeer.socket, 'consumerScore', { consumerId: consumer.id, score }); + }); + + consumer.on('layerschange', (layers) => + { + this._notification( + consumerPeer.socket, + 'consumerLayersChanged', + { + consumerId : consumer.id, + spatialLayer : layers ? layers.spatialLayer : null, + temporalLayer : layers ? layers.temporalLayer : null + } + ); + }); + + // Send a protoo request to the remote Peer with Consumer parameters. + try + { + await this._request( + consumerPeer.socket, + 'newConsumer', + { + peerId : producerPeer.id, + kind : producer.kind, + producerId : producer.id, + id : consumer.id, + kind : consumer.kind, + rtpParameters : consumer.rtpParameters, + type : consumer.type, + appData : producer.appData, + producerPaused : consumer.producerPaused + } + ); + + // Now that we got the positive response from the remote Peer and, if + // video, resume the Consumer to ask for an efficient key frame. + if (producer.kind === 'video') + await consumer.resume(); + + this._notification( + consumerPeer.socket, + 'consumerScore', + { + consumerId : consumer.id, + score : consumer.score + } + ); + } + catch (error) + { + logger.warn('_createConsumer() | failed:%o', error); + } } - _updateMaxBitrate() + _timeoutCallback(callback) { - if (this._mediaRoom.closed) - return; + let called = false; - const numPeers = this._mediaRoom.peers.length; - const previousMaxBitrate = this._maxBitrate; - let newMaxBitrate; + const interval = setTimeout( + () => + { + if (called) + return; + called = true; + callback(new Error('Request timeout.')); + }, + 10000 + ); - if (numPeers <= 2) + return (...args) => { - newMaxBitrate = MAX_BITRATE; + if (called) + return; + called = true; + clearTimeout(interval); + + callback(...args); + }; + } + + _request(socket, method, data = {}) + { + return new Promise((resolve, reject) => + { + socket.emit( + 'request', + { method, data }, + this._timeoutCallback((err, response) => + { + if (err) + { + reject(err); + } + else + { + resolve(response); + } + }) + ); + }); + } + + _notification(socket, method, data = {}, broadcast = false) + { + if (broadcast) + { + socket.broadcast.to(this._roomId).emit( + 'notification', { method, data } + ); } else { - newMaxBitrate = Math.round(MAX_BITRATE / ((numPeers - 1) * BITRATE_FACTOR)); - - if (newMaxBitrate < MIN_BITRATE) - newMaxBitrate = MIN_BITRATE; + socket.emit('notification', { method, data }); } - - this._maxBitrate = newMaxBitrate; - - for (const peer of this._mediaRoom.peers) - { - for (const transport of peer.transports) - { - if (transport.direction === 'send') - { - transport.setMaxBitrate(newMaxBitrate) - .catch((error) => - { - logger.error('transport.setMaxBitrate() failed: %s', String(error)); - }); - } - } - } - - logger.info( - '_updateMaxBitrate() [num peers:%s, before:%skbps, now:%skbps]', - numPeers, - Math.round(previousMaxBitrate / 1000), - Math.round(newMaxBitrate / 1000)); } } diff --git a/server/lib/homer.js b/server/lib/homer.js index 83106c2..9efcb36 100644 --- a/server/lib/homer.js +++ b/server/lib/homer.js @@ -54,7 +54,7 @@ function handleRoom(room, stream) Object.assign({}, baseEvent, { event : 'room.newpeer', - peerName : peer.name, + peerId : peer.id, rtpCapabilities : peer.rtpCapabilities }), stream); @@ -67,7 +67,7 @@ function handlePeer(peer, baseEvent, stream) { baseEvent = Object.assign({}, baseEvent, { - peerName : peer.name + peerId : peer.id }); peer.on('close', (originator) => diff --git a/server/package.json b/server/package.json index 0ad8bc0..1762e0c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,20 +1,24 @@ { "name": "multiparty-meeting-server", - "version": "2.0.0", + "version": "3.0.0", "private": true, "description": "multiparty meeting server", "author": "Håvar Aambø Fosstveit ", "license": "MIT", "main": "lib/index.js", "dependencies": { + "awaitqueue": "^1.0.0", "base-64": "^0.1.0", "colors": "^1.1.2", "compression": "^1.7.3", "debug": "^4.1.0", "express": "^4.16.3", - "mediasoup": "^2.6.11", - "passport-dataporten": "^1.3.0", - "socket.io": "^2.1.1" + "express-session": "^1.16.1", + "mediasoup": "^3.0.12", + "openid-client": "^2.5.0", + "passport": "^0.4.0", + "socket.io": "^2.1.1", + "spdy": "^4.0.0" }, "devDependencies": { "gulp": "^4.0.0", diff --git a/server/server.js b/server/server.js index de42cdc..366181e 100755 --- a/server/server.js +++ b/server/server.js @@ -6,15 +6,20 @@ process.title = 'multiparty-meeting-server'; const config = require('./config/config'); const fs = require('fs'); -const https = require('https'); const http = require('http'); +const spdy = require('spdy'); const express = require('express'); const compression = require('compression'); +const mediasoup = require('mediasoup'); +const AwaitQueue = require('awaitqueue'); const Logger = require('./lib/Logger'); const Room = require('./lib/Room'); -const Dataporten = require('passport-dataporten'); const utils = require('./util'); const base64 = require('base-64'); +// auth +const passport = require('passport'); +const { Issuer, Strategy } = require('openid-client'); +const session = require('express-session'); /* eslint-disable no-console */ console.log('- process.env.DEBUG:', process.env.DEBUG); @@ -22,11 +27,18 @@ console.log('- config.mediasoup.logLevel:', config.mediasoup.logLevel); console.log('- config.mediasoup.logTags:', config.mediasoup.logTags); /* eslint-enable no-console */ -// Start the mediasoup server. -const mediaServer = require('./mediasoup'); - const logger = new Logger(); +const queue = new AwaitQueue(); + +// mediasoup Workers. +// @type {Array} +const mediasoupWorkers = []; + +// Index of next mediasoup Worker to use. +// @type {Number} +let nextMediasoupWorkerIdx = 0; + // Map of Room instances indexed by roomId. const rooms = new Map(); @@ -38,147 +50,377 @@ const tls = }; const app = express(); +let httpsServer; +let oidcClient; +let oidcStrategy; -app.use(compression()); - -const dataporten = new Dataporten.Setup(config.oauth2); - -app.all('*', (req, res, next) => +passport.serializeUser((user, done) => { - if (req.secure) + done(null, user); +}); + +passport.deserializeUser((user, done) => +{ + done(null, user); +}); + +const auth = config.auth; + +async function run() +{ + if ( + typeof(auth) !== 'undefined' && + typeof(auth.issuerURL) !== 'undefined' && + typeof(auth.clientOptions) !== 'undefined' + ) { - return next(); + Issuer.discover(auth.issuerURL).then( async (oidcIssuer) => + { + // Setup authentication + await setupAuth(oidcIssuer); + + // Run a mediasoup Worker. + await runMediasoupWorkers(); + + // Run HTTPS server. + await runHttpsServer(); + + // Run WebSocketServer. + await runWebSocketServer(); + }) + .catch((err) => + { + logger.error(err); + }); + } + else + { + logger.error('Auth is not configure properly!'); + + // Run a mediasoup Worker. + await runMediasoupWorkers(); + + // Run HTTPS server. + await runHttpsServer(); + + // Run WebSocketServer. + await runWebSocketServer(); } - res.redirect(`https://${req.hostname}${req.url}`); -}); - -app.use(dataporten.passport.initialize()); -app.use(dataporten.passport.session()); - -app.get('/login', (req, res, next) => -{ - dataporten.passport.authenticate('dataporten', { - state : base64.encode(JSON.stringify({ - roomId : req.query.roomId, - peerName : req.query.peerName, - code : utils.random(10) - })) - - })(req, res, next); -}); - -dataporten.setupLogout(app, '/logout'); - -app.get('/', (req, res) => -{ - res.sendFile(`${__dirname}/public/chooseRoom.html`); -}); - -app.get( - '/auth-callback', - dataporten.passport.authenticate('dataporten', { failureRedirect: '/login' }), - (req, res) => + // Log rooms status every 30 seconds. + setInterval(() => { - const state = JSON.parse(base64.decode(req.query.state)); - - if (rooms.has(state.roomId)) + for (const room of rooms.values()) { - const data = + room.logStatus(); + } + }, 120000); +} + +async function setupAuth(oidcIssuer) +{ + oidcClient = new oidcIssuer.Client(auth.clientOptions); + + // ... any authorization request parameters go here + // client_id defaults to client.client_id + // redirect_uri defaults to client.redirect_uris[0] + // response type defaults to client.response_types[0], then 'code' + // scope defaults to 'openid' + const params = auth.clientOptions; + + // optional, defaults to false, when true req is passed as a first + // argument to verify fn + const passReqToCallback = false; + + // optional, defaults to false, when true the code_challenge_method will be + // resolved from the issuer configuration, instead of true you may provide + // any of the supported values directly, i.e. "S256" (recommended) or "plain" + const usePKCE = false; + const client = oidcClient; + + oidcStrategy = new Strategy( + { client, params, passReqToCallback, usePKCE }, + (tokenset, userinfo, done) => + { + const user = { - peerName : state.peerName, - name : req.user.data.displayName, - picture : req.user.data.photos[0] + id : tokenset.claims.sub, + provider : tokenset.claims.iss, + _userinfo : userinfo, + _claims : tokenset.claims }; - const room = rooms.get(state.roomId); + if (typeof(userinfo.picture) !== 'undefined') + { + if (!userinfo.picture.match(/^http/g)) + { + user.Photos = [ { value: `data:image/jpeg;base64, ${userinfo.picture}` } ]; + } + else + { + user.Photos = [ { value: userinfo.picture } ]; + } + } - room.authCallback(data); + if (userinfo.nickname != null) + { + user.displayName = userinfo.nickname; + } + + if (userinfo.name != null) + { + user.displayName = userinfo.name; + } + + if (userinfo.email != null) + { + user.emails = [ { value: userinfo.email } ]; + } + + if (userinfo.given_name != null) + { + user.name = { givenName: userinfo.given_name }; + } + + if (userinfo.family_name != null) + { + user.name = { familyName: userinfo.family_name }; + } + + if (userinfo.middle_name != null) + { + user.name = { middleName: userinfo.middle_name }; + } + + return done(null, user); + } + ); + + passport.use('oidc', oidcStrategy); + + app.use(session({ + secret : config.cookieSecret, + resave : true, + saveUninitialized : true, + cookie : { secure: true } + })); + + app.use(passport.initialize()); + app.use(passport.session()); + + // login + app.get('/auth/login', (req, res, next) => + { + passport.authenticate('oidc', { + state : base64.encode(JSON.stringify({ + roomId : req.query.roomId, + peerId : req.query.peerId, + code : utils.random(10) + })) + })(req, res, next); + }); + + // logout + app.get('/auth/logout', (req, res) => + { + req.logout(); + res.redirect('/'); + }); + + // callback + app.get( + '/auth/callback', + passport.authenticate('oidc', { failureRedirect: '/auth/login' }), + (req, res) => + { + const state = JSON.parse(base64.decode(req.query.state)); + + if (rooms.has(state.roomId)) + { + let displayName; + let photo; + + if (req.user != null) + { + if (req.user.displayName != null) + displayName = req.user.displayName; + else + displayName = ''; + + if ( + req.user.Photos != null && + req.user.Photos[0] != null && + req.user.Photos[0].value != null + ) + photo = req.user.Photos[0].value; + else + photo = '/static/media/buddy.403cb9f6.svg'; + } + + const data = + { + peerId : state.peerId, + displayName : displayName, + picture : photo + }; + + const room = rooms.get(state.roomId); + + room.authCallback(data); + } + + res.send(''); + } + ); +} + +async function runHttpsServer() +{ + app.use(compression()); + + app.use('/.well-known/acme-challenge', express.static('public/.well-known/acme-challenge')); + + app.all('*', (req, res, next) => + { + if (req.secure) + { + return next(); } - res.send(''); - } -); + res.redirect(`https://${req.hostname}${req.url}`); + }); -// Serve all files in the public folder as static files. -app.use(express.static('public')); - -app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`)); - -const httpsServer = https.createServer(tls, app); - -httpsServer.listen(config.listeningPort, '0.0.0.0', () => -{ - logger.info('Server running on port: ', config.listeningPort); -}); - -const httpServer = http.createServer(app); - -httpServer.listen(config.listeningRedirectPort, '0.0.0.0', () => -{ - logger.info('Server redirecting port: ', config.listeningRedirectPort); -}); - -const io = require('socket.io')(httpsServer); - -// Handle connections from clients. -io.on('connection', (socket) => -{ - const { roomId, peerName } = socket.handshake.query; - - if (!roomId || !peerName) + app.get('/', (req, res) => { - logger.warn('connection request without roomId and/or peerName'); + res.sendFile(`${__dirname}/public/chooseRoom.html`); + }); - socket.disconnect(true); + // Serve all files in the public folder as static files. + app.use(express.static('public')); - return; - } + app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`)); - logger.info( - 'connection request [roomId:"%s", peerName:"%s"]', roomId, peerName); + httpsServer = spdy.createServer(tls, app); - let room; - - // If an unknown roomId, create a new Room. - if (!rooms.has(roomId)) + httpsServer.listen(config.listeningPort, '0.0.0.0', () => { - logger.info('creating a new Room [roomId:"%s"]', roomId); + logger.info('Server running on port: ', config.listeningPort); + }); - try - { - room = new Room(roomId, mediaServer, io); + const httpServer = http.createServer(app); - global.APP_ROOM = room; - } - catch (error) + httpServer.listen(config.listeningRedirectPort, '0.0.0.0', () => + { + logger.info('Server redirecting port: ', config.listeningRedirectPort); + }); +} + +/** + * Create a protoo WebSocketServer to allow WebSocket connections from browsers. + */ +async function runWebSocketServer() +{ + const io = require('socket.io')(httpsServer); + + // Handle connections from clients. + io.on('connection', (socket) => + { + const { roomId, peerId } = socket.handshake.query; + + if (!roomId || !peerId) { - logger.error('error creating a new Room: %s', error); + logger.warn('connection request without roomId and/or peerId'); socket.disconnect(true); return; } - const logStatusTimer = setInterval(() => + logger.info( + 'connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); + + queue.push(async () => { - room.logStatus(); - }, 30000); + const room = await getOrCreateRoom({ roomId }); + + room.handleConnection({ peerId, socket }); + }) + .catch((error) => + { + logger.error('room creation or room joining failed:%o', error); + + socket.disconnect(true); + + return; + }); + }); +} + +/** + * Launch as many mediasoup Workers as given in the configuration file. + */ +async function runMediasoupWorkers() +{ + const { numWorkers } = config.mediasoup; + + logger.info('running %d mediasoup Workers...', numWorkers); + + for (let i = 0; i < numWorkers; ++i) + { + const worker = await mediasoup.createWorker( + { + logLevel : config.mediasoup.worker.logLevel, + logTags : config.mediasoup.worker.logTags, + rtcMinPort : config.mediasoup.worker.rtcMinPort, + rtcMaxPort : config.mediasoup.worker.rtcMaxPort + }); + + worker.on('died', () => + { + logger.error( + 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); + + setTimeout(() => process.exit(1), 2000); + }); + + mediasoupWorkers.push(worker); + } +} + +/** + * Get next mediasoup Worker. + */ +function getMediasoupWorker() +{ + const worker = mediasoupWorkers[nextMediasoupWorkerIdx]; + + if (++nextMediasoupWorkerIdx === mediasoupWorkers.length) + nextMediasoupWorkerIdx = 0; + + return worker; +} + +/** + * Get a Room instance (or create one if it does not exist). + */ +async function getOrCreateRoom({ roomId }) +{ + let room = rooms.get(roomId); + + // If the Room does not exist create a new one. + if (!room) + { + logger.info('creating a new Room [roomId:%s]', roomId); + + const mediasoupWorker = getMediasoupWorker(); + + room = await Room.create({ mediasoupWorker, roomId }); rooms.set(roomId, room); - - room.on('close', () => - { - rooms.delete(roomId); - clearInterval(logStatusTimer); - }); - } - else - { - room = rooms.get(roomId); + room.on('close', () => rooms.delete(roomId)); } - socket.room = roomId; + return room; +} - room.handleConnection(peerName, socket); -}); +run(); \ No newline at end of file