From 30f42d6ced06250eccf3fad6c0f2341c6541d25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Mon, 3 Jun 2019 11:55:23 +0200 Subject: [PATCH] Mostly working mediasoup v3 --- .gitignore | 2 +- app/package.json | 10 +- app/src/RoomClient.js | 1818 ++++++++++------- app/src/Spotlights.js | 135 +- app/src/actions/stateActions.js | 92 +- app/src/components/Containers/Me.js | 6 +- app/src/components/Containers/Peer.js | 32 +- app/src/components/Containers/Volume.js | 2 +- app/src/components/Controls/Sidebar.js | 4 +- .../MeetingDrawer/FileSharing/File.js | 16 +- .../MeetingDrawer/FileSharing/FileSharing.js | 18 +- .../MeetingDrawer/ParticipantList/ListPeer.js | 10 +- .../ParticipantList/ParticipantList.js | 42 +- app/src/components/MeetingViews/Democratic.js | 4 +- app/src/components/MeetingViews/Filmstrip.js | 42 +- app/src/components/Selectors.js | 10 +- .../VideoContainers/FullScreenView.js | 7 - app/src/components/appPropTypes.js | 30 +- app/src/deviceInfo.js | 31 + app/src/index.js | 12 +- app/src/reducers/consumers.js | 38 +- app/src/reducers/me.js | 24 +- app/src/reducers/peerVolumes.js | 14 +- app/src/reducers/peers.js | 12 +- app/src/reducers/room.js | 16 +- app/src/urlFactory.js | 4 +- server/config/config.example.js | 120 +- server/lib/Room.js | 1391 ++++++++----- server/lib/homer.js | 4 +- server/package.json | 5 +- server/server.js | 353 ++-- 31 files changed, 2563 insertions(+), 1741 deletions(-) create mode 100644 app/src/deviceInfo.js diff --git a/.gitignore b/.gitignore index 122f774..7f87e51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ /app/build/ -/app/public/config.js +/app/public/config/config.js /app/public/images/logo.* /server/config/ !/server/config/config.example.js diff --git a/app/package.json b/app/package.json index 05a3baf..943109e 100644 --- a/app/package.json +++ b/app/package.json @@ -8,13 +8,14 @@ "dependencies": { "@material-ui/core": "^3.9.2", "@material-ui/icons": "^3.0.2", + "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", @@ -169,7 +170,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/src/RoomClient.js b/app/src/RoomClient.js index 105578f..8ce133a 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,6 +56,13 @@ const VIDEO_CONSTRAINS = } }; +const VIDEO_ENCODINGS = +[ + { maxBitrate: 180000, scaleResolutionDownBy: 4 }, + { maxBitrate: 360000, scaleResolutionDownBy: 2 }, + { maxBitrate: 1500000, scaleResolutionDownBy: 1 } +]; + let store; const AudioContext = window.AudioContext // Default @@ -74,13 +81,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 +98,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,7 +114,7 @@ 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'); @@ -114,10 +127,13 @@ export default class RoomClient // 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 @@ -156,6 +172,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 +194,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 +226,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,13 +302,16 @@ 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 = `/auth/login?roomId=${this._room.roomId}&peerName=${this._peerName}`; + const url = `/auth/login?roomId=${this._roomId}&peerId=${this._peerId}`; this._loginWindow = window.open(url, 'loginWindow'); } @@ -324,17 +382,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 +407,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 +438,7 @@ export default class RoomClient try { - await this.sendRequest('change-profile-picture', { picture }); + await this.sendRequest('changeProfilePicture', { picture }); } catch (error) { @@ -386,13 +455,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 +475,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,15 +543,20 @@ 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); @@ -494,9 +576,10 @@ export default class RoomClient this._webTorrent.seed(files, (newTorrent) => { - this.notify( - 'Torrent successfully created' - ); + store.dispatch(requestActions.notify( + { + text : 'File successfully shared.' + })); const { displayName, picture } = store.getState().settings; const file = { @@ -525,13 +608,17 @@ export default class RoomClient try { - await this.sendRequest('send-file', { file }); + await this.sendRequest('sendFile', { file }); } 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 +632,7 @@ export default class RoomClient chatHistory, fileHistory, lastN - } = await this.sendRequest('server-history'); + } = await this.sendRequest('serverHistory'); if (chatHistory.length > 0) { @@ -566,7 +653,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,20 +664,14 @@ 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()'); @@ -603,7 +684,11 @@ export default class RoomClient { 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.' + })); } } @@ -616,7 +701,7 @@ export default class RoomClient if (this._micProducer) this._micProducer.resume(); else if (this._room.canSend('audio')) - await this._setMicProducer(); + await this.enableMic(); else throw new Error('cannot send audio'); } @@ -624,7 +709,11 @@ export default class RoomClient { logger.error('unmuteMic() | 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.' + })); } } @@ -635,26 +724,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); } } } @@ -712,78 +792,6 @@ export default class RoomClient }); } - 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); @@ -840,7 +848,7 @@ export default class RoomClient if (volume !== this._micProducer.volume) { this._micProducer.volume = volume; - store.dispatch(stateActions.setPeerVolume(this._peerName, volume)); + store.dispatch(stateActions.setPeerVolume(this._peerId, volume)); } }); @@ -878,6 +886,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 +901,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 +939,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 +954,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 +972,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 +1024,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 +1086,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 +1095,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)); @@ -1076,18 +1128,11 @@ export default class RoomClient } } - join() + async join() { 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.setRoomState('connecting')); @@ -1096,31 +1141,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,153 +1176,386 @@ 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 (consumer.kind === 'audio') + { + 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 (volume !== consumer.volume) + { + consumer.volume = volume; + + store.dispatch(stateActions.setPeerVolume(consumer.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(); - 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 { file } = notification.data; - return; - } + store.dispatch(stateActions.addFile(file)); - 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, device } = notification.data; + + store.dispatch( + stateActions.addPeer({ id, displayName, 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(); + 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); + } } }); } @@ -1288,113 +1564,152 @@ export default class RoomClient { 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 } = 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() + needExtension : this._screenSharing.needExtension(), + canShareFiles : this._torrentSupport })); + const { peers } = await this.sendRequest( + 'join', + { + displayName : displayName, + 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')) + await this.enableMic(); - if (this._room.canSend('video')) + if (this._mediasoupDevice.canProduce('video')) await this.enableWebcam(); } @@ -1405,22 +1720,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 +1731,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 +1747,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 +1775,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 +1824,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 +1837,60 @@ 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)); - }); - - 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.disableMic() + .catch(() => {}); }); 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'); + this._micProducer.hark = hark(harkStream, { play: false }); // eslint-disable-next-line no-unused-vars - producer.hark.on('volume_change', (dBs, threshold) => + this._micProducer.hark.on('volume_change', (dBs, threshold) => { // The exact formula to convert from dBs (-100..0) to linear (0..1) is: // Math.pow(10, dBs / 20) @@ -1584,10 +1902,10 @@ 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)); } }); @@ -1602,22 +1920,71 @@ export default class RoomClient } 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 + { + 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 { @@ -1627,7 +1994,7 @@ export default class RoomClient 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 +2002,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 +2154,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 +2221,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 +2381,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/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..26d0baf 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 } }; }; @@ -57,19 +57,25 @@ export const setSettingsOpen = ({ settingsOpen }) => 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, + needExtension, + canShareFiles +}) => { return { type : 'SET_MEDIA_CAPABILITIES', - payload : { canSendMic, canSendWebcam } + payload : { canSendMic, canSendWebcam, canShareScreen, needExtension, canShareFiles } }; }; @@ -150,27 +156,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 +232,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 +280,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 +320,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 +368,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 +392,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 } }; }; @@ -536,10 +566,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 +577,10 @@ export const loggedIn = () => type : 'LOGGED_IN' }); -export const setSelectedPeer = (selectedPeerName) => +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..60d30e8 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -95,7 +95,7 @@ const Me = (props) => roomClient.changeDisplayName(displayName); }} > - + @@ -138,7 +138,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,7 +153,7 @@ 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 ); } } diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index d83100c..76ea10b 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -196,13 +196,6 @@ const Peer = (props) => }} >
- { videoVisible && !webcamConsumer.supported ? -
-

incompatible video

-
- :null - } - { !videoVisible ?

this video is paused

@@ -210,7 +203,7 @@ const Peer = (props) => :null } - { videoVisible && webcamConsumer.supported ? + { videoVisible ?
setWebcamHover(true)} @@ -240,8 +233,8 @@ const Peer = (props) => onClick={() => { 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 ? @@ -295,7 +288,7 @@ const Peer = (props) => audioCodec={micConsumer ? micConsumer.codec : null} videoCodec={webcamConsumer ? webcamConsumer.codec : null} > - +
@@ -323,13 +316,6 @@ const Peer = (props) => }, 2000); }} > - { screenVisible && !screenConsumer.supported ? -
-

incompatible video

-
- :null - } - { !screenVisible ?

this video is paused

@@ -337,7 +323,7 @@ const Peer = (props) => :null } - { screenVisible && screenConsumer.supported ? + { screenVisible ?
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 +456,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/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 index 406a9f3..cc9fe86 100644 --- a/app/src/components/Controls/Sidebar.js +++ b/app/src/components/Controls/Sidebar.js @@ -158,8 +158,8 @@ const Sidebar = (props) => onClick={() => { micState === 'on' ? - roomClient.muteMic() : - roomClient.unmuteMic(); + roomClient.disableMic() : + roomClient.enableMic(); }} > { micState === 'on' ? diff --git a/app/src/components/MeetingDrawer/FileSharing/File.js b/app/src/components/MeetingDrawer/FileSharing/File.js index c532ef5..05b92b0 100644 --- a/app/src/components/MeetingDrawer/FileSharing/File.js +++ b/app/src/components/MeetingDrawer/FileSharing/File.js @@ -55,7 +55,7 @@ class File extends React.PureComponent { const { roomClient, - torrentSupport, + canShareFiles, file, classes } = this.props; @@ -105,7 +105,7 @@ class File extends React.PureComponent {magnet.decode(file.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/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 59ab7ec..8715b9d 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -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..fac3e74 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -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..828db9b 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -139,9 +139,9 @@ class Democratic extends React.PureComponent { return ( ); diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index 3f76809..91884a2 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -104,11 +104,11 @@ class Filmstrip extends React.PureComponent // 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) + if (this.props.selectedPeerId) { - return this.props.selectedPeerName; + return this.props.selectedPeerId; } if (this.state.lastSpeaker) @@ -116,23 +116,23 @@ class Filmstrip extends React.PureComponent return this.state.lastSpeaker; } - const peerNames = Object.keys(this.props.peers); + const peerIds = Object.keys(this.props.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 = () => { let ratio = 4 / 3; - if (this.isSharingCamera(this.getActivePeerName())) + if (this.isSharingCamera(this.getActivePeerId())) { ratio *= 2; } @@ -202,12 +202,12 @@ class Filmstrip extends React.PureComponent classes } = this.props; - const activePeerName = this.getActivePeerName(); + const activePeerId = this.getActivePeerId(); return (
    - { peers[activePeerName] ? + { peers[activePeerId] ?
    :null @@ -226,23 +226,23 @@ class Filmstrip extends React.PureComponent
    - { 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)} + key={peerId} + onClick={() => roomClient.setSelectedPeer(peerId)} className={classnames(classes.film, { - selected : this.props.selectedPeerName === peerName, - active : this.state.lastSpeaker === peerName + selected : this.props.selectedPeerId === peerId, + active : this.state.lastSpeaker === peerId })} >
    @@ -276,7 +276,7 @@ Filmstrip.propTypes = { peers : PropTypes.object.isRequired, consumers : PropTypes.object.isRequired, myName : PropTypes.string.isRequired, - selectedPeerName : PropTypes.string, + selectedPeerId : PropTypes.string, spotlightsLength : PropTypes.number, spotlights : PropTypes.array.isRequired, classes : PropTypes.object.isRequired @@ -288,7 +288,7 @@ const mapStateToProps = (state) => return { activeSpeakerName : state.room.activeSpeakerName, - selectedPeerName : state.room.selectedPeerName, + selectedPeerId : state.room.selectedPeerId, peers : state.peers, consumers : state.consumers, myName : state.me.name, 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/VideoContainers/FullScreenView.js b/app/src/components/VideoContainers/FullScreenView.js index 85a9e0d..cbf2b73 100644 --- a/app/src/components/VideoContainers/FullScreenView.js +++ b/app/src/components/VideoContainers/FullScreenView.js @@ -102,13 +102,6 @@ const FullScreenView = (props) => return (
    - { consumerVisible && !consumer.supported ? -
    -

    incompatible video

    -
    - :null - } -
    =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..b4455d5 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -3,12 +3,12 @@ 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,13 +44,15 @@ function run() { logger.debug('run() [environment:%s]', process.env.NODE_ENV); - const peerName = randomString({ length: 8 }).toLowerCase(); + const peerId = randomString({ length: 8 }).toLowerCase(); const urlParser = new UrlParse(window.location.href, true); let roomId = (urlParser.pathname).substr(1) ? (urlParser.pathname).substr(1).toLowerCase() : urlParser.query.roomId.toLowerCase(); const produce = urlParser.query.produce !== 'false'; + const consume = urlParser.query.consume !== 'false'; const useSimulcast = urlParser.query.simulcast === 'true'; + const forceTcp = urlParser.query.forceTcp === 'true'; if (!roomId) { @@ -80,21 +82,21 @@ function run() const roomUrl = roomUrlParser.toString(); // 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/me.js b/app/src/reducers/me.js index 4750f0c..948988f 100644 --- a/app/src/reducers/me.js +++ b/app/src/reducers/me.js @@ -1,11 +1,12 @@ 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 +25,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,9 +46,22 @@ const me = (state = initialState, action) => case 'SET_MEDIA_CAPABILITIES': { - const { canSendMic, canSendWebcam } = action.payload; + const { + canSendMic, + canSendWebcam, + canShareScreen, + needExtension, + canShareFiles + } = action.payload; - return { ...state, canSendMic, canSendWebcam }; + return { + ...state, + canSendMic, + canSendWebcam, + canShareScreen, + needExtension, + canShareFiles + }; } case 'SET_SCREEN_CAPABILITIES': 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..b5a3270 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -53,12 +53,12 @@ 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 ]); + return omit(state, [ action.payload.peerId ]); } case 'SET_PEER_DISPLAY_NAME': @@ -69,25 +69,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..446b350 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -5,14 +5,14 @@ const initialState = 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 }; @@ -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': @@ -69,9 +69,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': @@ -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 4e7f34f..ef41311 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -1,3 +1,5 @@ +const os = require('os'); + module.exports = { // oAuth2 conf @@ -9,21 +11,21 @@ module.exports = could be discovered on: issuerURL + '/.well-known/openid-configuration' */ - issuerURL : 'https://example.com' - clientOptions : + issuerURL : 'https://example.com', + clientOptions : { - client_id : '', - client_secret : '', - scope : 'openid email profile' + 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' + redirect_uri : 'https://client.example.com/auth/callback' } }, // session cookie secret - cookieSecret : 'T0P-S3cR3t_cook!e', + cookieSecret : 'T0P-S3cR3t_cook!e', // Listening hostname for `gulp live|open`. - domain : 'localhost', - tls : + domain : 'localhost', + tls : { cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`, key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem` @@ -33,59 +35,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..b1fa234 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'); + 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,860 @@ 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, device, rtpCapabilities } = request.data; + + // Store client data into the protoo Peer data object. + peer.data.displayName = displayName; + 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, + 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, + device : device + }, + true + ); + + logger.debug( + 'peer joined [peeerId: %s, displayName: %s, device: %o]', + peer.id, displayName, 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + 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(null); + + break; + } + + case 'unlockRoom': + { + this._locked = false; + + // Spread to others + this._notification(peer.socket, 'unlockRoom', { + peerId : peer.id + }, true); + + // Return no error + cb(null); + + break; + } + + case 'sendFile': + { + const { file } = request.data; + + this._fileHistory.push(file); + + // Spread to others + this._notification(peer.socket, 'sendFile', { + file : file + }, true); + + // Return no error + cb(null); + + 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(null); + 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 fb16c02..1762e0c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,19 +1,20 @@ { "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", "express-session": "^1.16.1", - "mediasoup": "^2.6.11", + "mediasoup": "^3.0.12", "openid-client": "^2.5.0", "passport": "^0.4.0", "socket.io": "^2.1.1", diff --git a/server/server.js b/server/server.js index b74a76c..6c24643 100755 --- a/server/server.js +++ b/server/server.js @@ -10,6 +10,8 @@ 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 utils = require('./util'); @@ -17,7 +19,7 @@ const base64 = require('base-64'); // auth const passport = require('passport'); const { Issuer, Strategy } = require('openid-client'); -const session = require('express-session') +const session = require('express-session'); /* eslint-disable no-console */ console.log('- process.env.DEBUG:', process.env.DEBUG); @@ -25,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(); @@ -40,35 +49,84 @@ const tls = key : fs.readFileSync(config.tls.key) }; -let app = express(); +const app = express(); let httpsServer; let oidcClient; let oidcStrategy; -passport.serializeUser(function(user, done) +passport.serializeUser((user, done) => { done(null, user); }); -passport.deserializeUser(function(user, done) +passport.deserializeUser((user, done) => { done(null, user); }); -const auth=config.auth; +const auth = config.auth; -function setupAuth(oidcIssuer) +async function run() +{ + if ( + typeof(auth) !== 'undefined' && + typeof(auth.issuerURL) !== 'undefined' && + typeof(auth.clientOptions) !== 'undefined' + ) + { + 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(); + } + + // Log rooms status every 30 seconds. + setInterval(() => + { + for (const room of rooms.values()) + { + room.logStatus(); + } + }, 120000); +} + +async function setupAuth(oidcIssuer) { oidcClient = new oidcIssuer.Client(auth.clientOptions); - const params = - { - ...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' - }; + + // ... 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 @@ -78,63 +136,73 @@ function setupAuth(oidcIssuer) // 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; + const client = oidcClient; oidcStrategy = new Strategy( - { client, params, passReqToCallback, usePKCE }, - (tokenset, userinfo, done) => - { - let user = { - id : tokenset.claims.sub, - provider : tokenset.claims.iss, - _userinfo : userinfo, - _claims : tokenset.claims, + { client, params, passReqToCallback, usePKCE }, + (tokenset, userinfo, done) => + { + const user = + { + id : tokenset.claims.sub, + provider : tokenset.claims.iss, + _userinfo : userinfo, + _claims : tokenset.claims }; - - if ( typeof(userinfo.picture) !== 'undefined' ){ - if ( ! userinfo.picture.match(/^http/g) ) { + 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 } ]; + } + else + { + user.Photos = [ { value: userinfo.picture } ]; } } - if ( typeof(userinfo.nickname) !== 'undefined' ){ - user.displayName=userinfo.nickname; + if (typeof(userinfo.nickname) !== 'undefined') + { + user.displayName = userinfo.nickname; } - if ( typeof(userinfo.name) !== 'undefined' ){ - user.displayName=userinfo.name; + if (typeof(userinfo.name) !== 'undefined') + { + user.displayName = userinfo.name; } - if ( typeof(userinfo.email) !== 'undefined' ){ - user.emails=[{value: userinfo.email}]; + if (typeof(userinfo.email) !== 'undefined') + { + user.emails = [ { value: userinfo.email } ]; } - if ( typeof(userinfo.given_name) !== 'undefined' ){ - user.name={givenName: userinfo.given_name}; + if (typeof(userinfo.given_name) !== 'undefined') + { + user.name = { givenName: userinfo.given_name }; } - if ( typeof(userinfo.family_name) !== 'undefined' ){ - user.name={familyName: userinfo.family_name}; + if (typeof(userinfo.family_name) !== 'undefined') + { + user.name = { familyName: userinfo.family_name }; } - if ( typeof(userinfo.middle_name) !== 'undefined' ){ - user.name={middleName: userinfo.middle_name}; + if (typeof(userinfo.middle_name) !== 'undefined') + { + 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 } + secret : config.cookieSecret, + resave : true, + saveUninitialized : true, + cookie : { secure: true } })); app.use(passport.initialize()); @@ -145,20 +213,20 @@ function setupAuth(oidcIssuer) { passport.authenticate('oidc', { state : base64.encode(JSON.stringify({ - roomId : req.query.roomId, - peerName : req.query.peerName, - code : utils.random(10) + roomId : req.query.roomId, + peerId : req.query.peerId, + code : utils.random(10) })) })(req, res, next); }); // logout - app.get('/auth/logout', function(req, res) + app.get('/auth/logout', (req, res) => { req.logout(); res.redirect('/'); - } - ); + }); + // callback app.get( '/auth/callback', @@ -168,22 +236,30 @@ function setupAuth(oidcIssuer) const state = JSON.parse(base64.decode(req.query.state)); if (rooms.has(state.roomId)) - { - let displayName,photo - if (typeof(req.user) !== 'undefined'){ - if (typeof(req.user.displayName) !== 'undefined') displayName=req.user.displayName; - else displayName=""; + { + let displayName; + let photo; + + if (typeof(req.user) !== 'undefined') + { + if (typeof(req.user.displayName) !== 'undefined') + displayName = req.user.displayName; + else + displayName = ''; + if ( typeof(req.user.Photos) !== 'undefined' && typeof(req.user.Photos[0]) !== 'undefined' && typeof(req.user.Photos[0].value) !== 'undefined' - ) photo=req.user.Photos[0].value; - else photo="/static/media/buddy.403cb9f6.svg"; + ) + photo = req.user.Photos[0].value; + else + photo = '/static/media/buddy.403cb9f6.svg'; } - + const data = { - peerName : state.peerName, + peerId : state.peerId, name : displayName, picture : photo }; @@ -198,9 +274,12 @@ function setupAuth(oidcIssuer) ); } -function setupWebServer() { +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) @@ -234,19 +313,23 @@ function setupWebServer() { { logger.info('Server redirecting port: ', config.listeningRedirectPort); }); -}; +} -function setupSocketIO(){ +/** + * 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, peerName } = socket.handshake.query; + const { roomId, peerId } = socket.handshake.query; - if (!roomId || !peerName) + if (!roomId || !peerId) { - logger.warn('connection request without roomId and/or peerName'); + logger.warn('connection request without roomId and/or peerId'); socket.disconnect(true); @@ -254,72 +337,90 @@ function setupSocketIO(){ } logger.info( - 'connection request [roomId:"%s", peerName:"%s"]', roomId, peerName); + 'connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); - let room; - - // If an unknown roomId, create a new Room. - if (!rooms.has(roomId)) + queue.push(async () => { - logger.info('creating a new Room [roomId:"%s"]', roomId); + const room = await getOrCreateRoom({ roomId }); - try - { - room = new Room(roomId, mediaServer, io); - - global.APP_ROOM = room; - } - catch (error) - { - logger.error('error creating a new Room: %s', error); - - socket.disconnect(true); - - return; - } - - const logStatusTimer = setInterval(() => - { - room.logStatus(); - }, 30000); - - rooms.set(roomId, room); - - room.on('close', () => - { - rooms.delete(roomId); - clearInterval(logStatusTimer); - }); - } - else + room.handleConnection({ peerId, socket }); + }) + .catch((error) => { - room = rooms.get(roomId); - } + logger.error('room creation or room joining failed:%o', error); - socket.room = roomId; + socket.disconnect(true); - room.handleConnection(peerName, socket); + return; + }); }); } -if ( - typeof(auth) !== 'undefined' && - typeof(auth.issuerURL) !== 'undefined' && - typeof(auth.clientOptions) !== 'undefined' -) + +/** + * Launch as many mediasoup Workers as given in the configuration file. + */ +async function runMediasoupWorkers() { - Issuer.discover(auth.issuerURL).then((oidcIssuer) => + const { numWorkers } = config.mediasoup; + + logger.info('running %d mediasoup Workers...', numWorkers); + + for (let i = 0; i < numWorkers; ++i) { - setupAuth(oidcIssuer); - setupWebServer(); - setupSocketIO(); - }).catch((err) => { - logger.error(err); + 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); } - ); -} else -{ - logger.error('Auth is not configure properly!'); - setupWebServer(); - setupSocketIO(); } +/** + * 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)); + } + + return room; +} + +run(); \ No newline at end of file