diff --git a/CHANGELOG.md b/CHANGELOG.md index 3372233..bda55dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # Changelog -### 3.1 +## 3.2 + +* Add munin plugin +* Add muted=true search param to disble audio by deffault +* Modify webtorrent tracker +* Add key shortcut `space` for audio mute +* Add key shortcut `v` for video mute +* Add user configurable LastN +* Add option to sticky top bar (sticky by default) +* update mediasoup server +* Add simulcast options to app config (disabled by default) +* Add stats option to get counts of rooms and peers +* Add httpOnly option for loadbalancer backend setups +* LTI integration for LMS systems like moodle +* Add muted=false search parameter +* Add translations (12+1 languages) +* Add support IPv6 +* Many other fixes and refactorings + +## 3.1 + * Browser session storage * Virtual lobby for rooms * Allow minimum TLSv1.2 and recommended ciphers @@ -9,7 +29,8 @@ * Internationalization support * Can require sign in for access -### 3.0 +## 3.0 + * Updated to mediasoup v3 * Replace lib "passport-datporten" with "openid-client" (a general OIDC certified client) - OpenID Connect discovery @@ -18,25 +39,30 @@ - Notice it does not supports node 11.x * Updated to Material UI v4 - ### 2.0 +## 2.0 + * Material UI * Separate settings for lastN for desktop and mobile - ### 1.2 +## 1.2 + * Add Lock Room feature * Fix suspended Web Audio context / fixed delayed getUsermedia * Added support for the new getdisplaymedia API in Chrome 72 -### 1.1 +## 1.1 + * Moved Filesharing code out from React code to RoomClient * Major cleanup of CSS. Variables for most colors and sizes exposed in :root * Started using React Context instead of middleware * Small fixes to buttons and layout -### 1.0 +## 1.0 + * Fixed toolarea button based on feedback from users * Added possibility to move video to separate window * Added SIP gateway -### RC1 1.0 +## RC1 1.0 + * First stable release? diff --git a/README.md b/README.md index cd5db22..2f1de98 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A WebRTC meeting service using [mediasoup](https://mediasoup.org). +![](demo.gif) + Try it online at https://letsmeet.no. You can add /roomname to the URL for specifying a room. ## Features @@ -15,7 +17,19 @@ Try it online at https://letsmeet.no. You can add /roomname to the URL for speci ## Docker If you want the automatic approach, you can find a docker image [here](https://hub.docker.com/r/misi/mm/). +## Ansible +If you want the ansible approach, you can find ansible role [here](https://github.com/misi/mm-ansible/). +[![asciicast](https://asciinema.org/a/311365.svg)](https://asciinema.org/a/311365) + + ## Manual installation +* Prerequisites: +Currently multiparty-meeting will only run on nodejs v10.* +To install see here [here](https://github.com/nodesource/distributions/blob/master/README.md#debinstall). + +```bash +$ sudo apt install npm build-essentials redis +``` * Clone the project: @@ -50,7 +64,6 @@ This will build the client application and copy everythink to `server/public` fr * Set up the server: ```bash -$ sudo apt install redis $ cd .. $ cd server $ npm install @@ -64,7 +77,8 @@ $ npm install $ cd server $ npm start ``` -* test your service in a webRTC enabled browser: `https://yourDomainOrIPAdress:3443/roomname` +* Note: Do not run the server as root. If you need to use port 80/443 make a iptables-mapping for that or use systemd configuration for that (see futher down this doc). +* Test your service in a webRTC enabled browser: `https://yourDomainOrIPAdress:3443/roomname` ## Deploy it in a server @@ -74,14 +88,14 @@ $ cp multiparty-meeting.service /etc/systemd/system/ $ edit /etc/systemd/system/multiparty-meeting.service ``` -* reload systemd configuration and start service: +* Reload systemd configuration and start service: ```bash $ systemctl daemon-reload $ systemctl start multiparty-meeting ``` -* if you want to start multiparty-meeting at boot time: +* If you want to start multiparty-meeting at boot time: ```bash $ systemctl enable multiparty-meeting ``` @@ -114,4 +128,4 @@ MIT Contributions to this work were made on behalf of the GÉANT project, a project that has received funding from the European Union’s Horizon 2020 research and innovation programme under Grant Agreement No. 731122 (GN4-2). On behalf of GÉANT project, GÉANT Association is the sole owner of the copyright in all material which was developed by a member of the GÉANT project. -GÉANT Vereniging (Association) is registered with the Chamber of Commerce in Amsterdam with registration number 40535155 and operates in the UK as a branch of GÉANT Vereniging. Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK. \ No newline at end of file +GÉANT Vereniging (Association) is registered with the Chamber of Commerce in Amsterdam with registration number 40535155 and operates in the UK as a branch of GÉANT Vereniging. Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK. diff --git a/app/Procfile b/app/Procfile new file mode 100644 index 0000000..a25f475 --- /dev/null +++ b/app/Procfile @@ -0,0 +1,2 @@ +react: npm start +electron: node src/electron-wait-react \ No newline at end of file diff --git a/app/package.json b/app/package.json index ac81f80..89a116e 100644 --- a/app/package.json +++ b/app/package.json @@ -5,16 +5,20 @@ "description": "multiparty meeting service", "author": "Håvar Aambø Fosstveit ", "license": "MIT", + "homepage": "./", + "main": "src/electron-starter.js", "dependencies": { "@material-ui/core": "^4.5.1", "@material-ui/icons": "^4.5.1", "bowser": "^2.7.0", "dompurify": "^2.0.7", "domready": "^1.0.8", + "end-of-stream": "1.4.0", "file-saver": "^2.0.2", "hark": "^1.2.3", - "marked": "^0.7.0", - "mediasoup-client": "^3.2.7", + "is-electron": "^2.2.0", + "marked": "^0.8.0", + "mediasoup-client": "^3.5.4", "notistack": "^0.9.5", "prop-types": "^15.7.2", "random-string": "^0.2.0", @@ -23,7 +27,8 @@ "react-dom": "^16.10.2", "react-intl": "^3.4.0", "react-redux": "^7.1.1", - "react-scripts": "3.2.0", + "react-router-dom": "^5.1.2", + "react-scripts": "^3.3.0", "redux": "^4.0.4", "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", @@ -35,12 +40,13 @@ "webtorrent": "^0.107.16" }, "scripts": { - "analyze-main": "source-map-explorer build/static/js/main.*", - "analyze-chunk": "source-map-explorer build/static/js/2.*", + "analyze": "source-map-explorer build/static/js/*", "start": "HTTPS=true PORT=4443 react-scripts start", "build": "react-scripts build && mkdir -p ../server/public && rm -rf ../server/public/* && cp -r build/* ../server/public/", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "electron": "electron --no-sandbox .", + "dev": "nf start -p 3000" }, "browserslist": [ ">0.2%", @@ -49,8 +55,12 @@ "not op_mini all" ], "devDependencies": { + "electron": "^7.1.1", "eslint": "^6.5.1", "eslint-plugin-import": "^2.18.2", - "eslint-plugin-react": "^7.16.0" + "eslint-plugin-react": "^7.16.0", + "foreman": "^3.0.1", + "jest": "^24.9.0", + "redux-mock-store": "^1.5.3" } } diff --git a/app/public/chooseRoom.html b/app/public/chooseRoom.html deleted file mode 100644 index ee10a2b..0000000 --- a/app/public/chooseRoom.html +++ /dev/null @@ -1,86 +0,0 @@ - - - - - Multiparty Meeting - - - - -
-
- - - - - diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 43dab12..6d78565 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -1,9 +1,11 @@ // eslint-disable-next-line var config = { - loginEnabled : false, - developmentPort : 3443, - turnServers : [ + loginEnabled : false, + developmentPort : 3443, + productionPort : 443, + multipartyServer : 'letsmeet.no', + turnServers : [ { urls : [ 'turn:turn.example.com:443?transport=tcp' @@ -12,8 +14,29 @@ var config = credential : 'example' } ], - requestTimeout : 10000, - transportOptions : + /** + * If defaultResolution is set, it will override user settings when joining: + * low ~ 320x240 + * medium ~ 640x480 + * high ~ 1280x720 + * veryhigh ~ 1920x1080 + * ultra ~ 3840x2560 + **/ + defaultResolution : 'medium', + // Enable or disable simulcast for webcam video + simulcast : true, + // Enable or disable simulcast for screen sharing video + simulcastSharing : false, + // Simulcast encoding layers and levels + simulcastEncodings : + [ + { scaleResolutionDownBy: 4 }, + { scaleResolutionDownBy: 2 }, + { scaleResolutionDownBy: 1 } + ], + // Socket.io request timeout + requestTimeout : 10000, + transportOptions : { tcp : true }, @@ -51,6 +74,17 @@ var config = backgroundColor : '#518029' } } + }, + MuiBadge : + { + colorPrimary : + { + backgroundColor : '#5F9B2D', + '&:hover' : + { + backgroundColor : '#518029' + } + } } }, typography : diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index dfa4713..9b63866 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -26,13 +26,24 @@ let ScreenShare; let Spotlights; -const { - turnServers, +let turnServers, requestTimeout, transportOptions, lastN, - mobileLastN -} = window.config; + mobileLastN, + defaultResolution; + +if (process.env.NODE_ENV !== 'test') +{ + ({ + turnServers, + requestTimeout, + transportOptions, + lastN, + mobileLastN, + defaultResolution + } = window.config); +} const logger = new Logger('RoomClient'); @@ -72,11 +83,28 @@ const VIDEO_CONSTRAINS = } }; -const VIDEO_ENCODINGS = +const PC_PROPRIETARY_CONSTRAINTS = +{ + optional : [ { googDscp: true } ] +}; + +const VIDEO_SIMULCAST_ENCODINGS = [ - { maxBitrate: 180000, scaleResolutionDownBy: 4 }, - { maxBitrate: 360000, scaleResolutionDownBy: 2 }, - { maxBitrate: 1500000, scaleResolutionDownBy: 1 } + { scaleResolutionDownBy: 4 }, + { scaleResolutionDownBy: 2 }, + { scaleResolutionDownBy: 1 } +]; + +// Used for VP9 webcam video. +const VIDEO_KSVC_ENCODINGS = +[ + { scalabilityMode: 'S3T3_KEY' } +]; + +// Used for VP9 desktop sharing. +const VIDEO_SVC_ENCODINGS = +[ + { scalabilityMode: 'S3T3', dtx: true } ]; let store; @@ -97,13 +125,18 @@ export default class RoomClient } constructor( - { roomId, peerId, accessCode, device, useSimulcast, produce, forceTcp }) + { peerId, accessCode, device, useSimulcast, useSharingSimulcast, produce, forceTcp, displayName, muted } = {}) { - logger.debug( - 'constructor() [roomId: "%s", peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", forceTcp: "%s"]', - roomId, peerId, device.flag, useSimulcast, produce, forceTcp); + if (!peerId) + throw new Error('Missing peerId'); + else if (!device) + throw new Error('Missing device'); - this._signalingUrl = getSignalingUrl(peerId, roomId); + logger.debug( + 'constructor() [peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", forceTcp: "%s", displayName ""]', + peerId, device.flag, useSimulcast, produce, forceTcp, displayName); + + this._signalingUrl = null; // Closed flag. this._closed = false; @@ -114,12 +147,27 @@ export default class RoomClient // Wheter we force TCP this._forceTcp = forceTcp; + // Use displayName + if (displayName) + store.dispatch(settingsActions.setDisplayName(displayName)); + // Torrent support this._torrentSupport = null; // Whether simulcast should be used. this._useSimulcast = useSimulcast; + if ('simulcast' in window.config) + this._useSimulcast = window.config.simulcast; + + // Whether simulcast should be used for sharing + this._useSharingSimulcast = useSharingSimulcast; + + if ('simulcastSharing' in window.config) + this._useSharingSimulcast = window.config.simulcastSharing; + + this._muted = muted; + // This device this._device = device; @@ -136,8 +184,7 @@ export default class RoomClient this._signalingSocket = null; // The room ID - this._roomId = roomId; - store.dispatch(roomActions.setRoomName(roomId)); + this._roomId = null; // mediasoup-client Device instance. // @type {mediasoupClient.Device} @@ -146,11 +193,17 @@ export default class RoomClient // Our WebTorrent client this._webTorrent = null; + if (defaultResolution) + store.dispatch(settingsActions.setVideoResolution(defaultResolution)); + // Max spotlights - if (device.bowser.ios || device.bowser.mobile || device.bowser.android) - this._maxSpotlights = mobileLastN; - else + if (device.bowser.getPlatformType() === 'desktop') this._maxSpotlights = lastN; + else + this._maxSpotlights = mobileLastN; + + store.dispatch( + settingsActions.setLastN(this._maxSpotlights)); // Manager of spotlight this._spotlights = null; @@ -268,6 +321,7 @@ export default class RoomClient break; } + case ' ': case 'm': // Toggle microphone { if (this._micProducer) @@ -313,6 +367,16 @@ export default class RoomClient break; } + case 'v': // Toggle video + { + if (this._webcamProducer) + this.disableWebcam(); + else + this.enableWebcam(); + + break; + } + default: { break; @@ -480,7 +544,7 @@ export default class RoomClient store.dispatch( meActions.setDisplayNameInProgress(true)); - + try { await this.sendRequest('changeDisplayName', { displayName }); @@ -640,30 +704,26 @@ export default class RoomClient }) })); - this._webTorrent.seed(files, (torrent) => - { - const existingTorrent = this._webTorrent.get(torrent); - - if (existingTorrent) + this._webTorrent.seed( + files, + { announceList: [['wss://tracker.lab.vvc.niif.hu:443']] }, + (torrent) => { - return this._sendFile(existingTorrent.magnetURI); - } + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'filesharing.successfulFileShare', + defaultMessage : 'File successfully shared' + }) + })); - store.dispatch(requestActions.notify( - { - text : intl.formatMessage({ - id : 'filesharing.successfulFileShare', - defaultMessage : 'File successfully shared' - }) - })); + store.dispatch(fileActions.addFile( + this._peerId, + torrent.magnetURI + )); - store.dispatch(fileActions.addFile( - this._peerId, - torrent.magnetURI - )); - - this._sendFile(torrent.magnetURI); - }); + this._sendFile(torrent.magnetURI); + }); } // { file, name, picture } @@ -798,7 +858,7 @@ export default class RoomClient catch (error) { logger.error('unmuteMic() | failed: %o', error); - + store.dispatch(requestActions.notify( { type : 'error', @@ -811,6 +871,14 @@ export default class RoomClient } } + changeMaxSpotlights(maxSpotlights) + { + this._spotlights.maxSpotlights = maxSpotlights; + + store.dispatch( + settingsActions.setLastN(maxSpotlights)); + } + // Updated consumers based on spotlights async updateSpotlights(spotlights) { @@ -872,8 +940,9 @@ export default class RoomClient logger.debug( 'changeAudioDevice() | new selected webcam [device:%o]', device); - - this._micProducer.track.stop(); + + if (this._micProducer && this._micProducer.track) + this._micProducer.track.stop(); logger.debug('changeAudioDevice() | calling getUserMedia()'); @@ -887,9 +956,11 @@ export default class RoomClient const track = stream.getAudioTracks()[0]; - await this._micProducer.replaceTrack({ track }); + if (this._micProducer) + await this._micProducer.replaceTrack({ track }); - this._micProducer.volume = 0; + if (this._micProducer) + this._micProducer.volume = 0; const harkStream = new MediaStream(); @@ -925,9 +996,9 @@ export default class RoomClient store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); } }); - - store.dispatch( - producerActions.setProducerTrack(this._micProducer.id, track)); + if (this._micProducer && this._micProducer.id) + store.dispatch( + producerActions.setProducerTrack(this._micProducer.id, track)); store.dispatch(settingsActions.setSelectedAudioDevice(deviceId)); @@ -1195,6 +1266,75 @@ export default class RoomClient meActions.setMyRaiseHandStateInProgress(false)); } + async setMaxSendingSpatialLayer(spatialLayer) + { + logger.debug('setMaxSendingSpatialLayer() [spatialLayer:%s]', spatialLayer); + + try + { + if (this._webcamProducer) + await this._webcamProducer.setMaxSpatialLayer(spatialLayer); + if (this._screenSharingProducer) + await this._screenSharingProducer.setMaxSpatialLayer(spatialLayer); + } + catch (error) + { + logger.error('setMaxSendingSpatialLayer() | failed:"%o"', error); + } + } + + async setConsumerPreferredLayers(consumerId, spatialLayer, temporalLayer) + { + logger.debug( + 'setConsumerPreferredLayers() [consumerId:%s, spatialLayer:%s, temporalLayer:%s]', + consumerId, spatialLayer, temporalLayer); + + try + { + await this.sendRequest( + 'setConsumerPreferedLayers', { consumerId, spatialLayer, temporalLayer }); + + store.dispatch(consumerActions.setConsumerPreferredLayers( + consumerId, spatialLayer, temporalLayer)); + } + catch (error) + { + logger.error('setConsumerPreferredLayers() | failed:"%o"', error); + } + } + + async setConsumerPriority(consumerId, priority) + { + logger.debug( + 'setConsumerPriority() [consumerId:%s, priority:%d]', + consumerId, priority); + + try + { + await this.sendRequest('setConsumerPriority', { consumerId, priority }); + + store.dispatch(consumerActions.setConsumerPriority(consumerId, priority)); + } + catch (error) + { + logger.error('setConsumerPriority() | failed:%o', error); + } + } + + async requestConsumerKeyFrame(consumerId) + { + logger.debug('requestConsumerKeyFrame() [consumerId:%s]', consumerId); + + try + { + await this.sendRequest('requestConsumerKeyFrame', { consumerId }); + } + catch (error) + { + logger.error('requestConsumerKeyFrame() | failed:%o', error); + } + } + async _loadDynamicImports() { ({ default: WebTorrent } = await import( @@ -1240,10 +1380,16 @@ export default class RoomClient )); } - async join({ joinVideo }) + async join({ roomId, joinVideo }) { await this._loadDynamicImports(); + this._roomId = roomId; + + store.dispatch(roomActions.setRoomName(roomId)); + + this._signalingUrl = getSignalingUrl(this._peerId, roomId); + this._torrentSupport = WebTorrent.WEBRTC_SUPPORT; this._webTorrent = this._torrentSupport && new WebTorrent({ @@ -1406,6 +1552,7 @@ export default class RoomClient temporalLayers : temporalLayers, preferredSpatialLayer : spatialLayers - 1, preferredTemporalLayer : temporalLayers - 1, + priority : 1, codec : consumer.rtpParameters.codecs[0].mimeType.split('/')[1], track : consumer.track }, @@ -1477,7 +1624,7 @@ export default class RoomClient case 'enteredLobby': { store.dispatch(roomActions.setInLobby(true)); - + const { displayName } = store.getState().settings; const { picture } = store.getState().me; @@ -1646,7 +1793,7 @@ export default class RoomClient store.dispatch( roomActions.setJoinByAccessCode(joinByAccessCode)); - if (joinByAccessCode) + if (joinByAccessCode) { store.dispatch(requestActions.notify( { @@ -1686,10 +1833,10 @@ export default class RoomClient case 'changeDisplayName': { const { peerId, displayName, oldDisplayName } = notification.data; - + store.dispatch( peerActions.setPeerDisplayName(displayName, peerId)); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1700,26 +1847,26 @@ export default class RoomClient displayName }) })); - + break; } - + case 'changePicture': { const { peerId, picture } = notification.data; - + store.dispatch(peerActions.setPeerPicture(peerId, picture)); - + break; } - + case 'chatMessage': { const { peerId, chatMessage } = notification.data; - + store.dispatch( chatActions.addResponseMessage({ ...chatMessage, peerId })); - + if ( !store.getState().toolarea.toolAreaOpen || (store.getState().toolarea.toolAreaOpen && @@ -1730,16 +1877,16 @@ export default class RoomClient roomActions.setToolbarsVisible(true)); this._soundNotification(); } - + break; } - + case 'sendFile': { const { peerId, magnetUri } = notification.data; - + store.dispatch(fileActions.addFile(peerId, magnetUri)); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1747,7 +1894,7 @@ export default class RoomClient defaultMessage : 'New file available' }) })); - + if ( !store.getState().toolarea.toolAreaOpen || (store.getState().toolarea.toolAreaOpen && @@ -1758,27 +1905,27 @@ export default class RoomClient roomActions.setToolbarsVisible(true)); this._soundNotification(); } - + break; } - + case 'producerScore': { const { producerId, score } = notification.data; - + store.dispatch( producerActions.setProducerScore(producerId, score)); - + break; } - + case 'newPeer': { - const { id, displayName, picture, device } = notification.data; - + const { id, displayName, picture } = notification.data; + store.dispatch( - peerActions.addPeer({ id, displayName, picture, device, consumers: [] })); - + peerActions.addPeer({ id, displayName, picture, consumers: [] })); + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1788,20 +1935,20 @@ export default class RoomClient displayName }) })); - + break; } - + case 'peerClosed': { const { peerId } = notification.data; - + store.dispatch( peerActions.removePeer(peerId)); - + break; } - + case 'consumerClosed': { const { consumerId } = notification.data; @@ -1835,7 +1982,7 @@ export default class RoomClient store.dispatch( consumerActions.setConsumerPaused(consumerId, 'remote')); - + break; } @@ -1941,7 +2088,9 @@ export default class RoomClient id, iceParameters, iceCandidates, - dtlsParameters + dtlsParameters, + iceServers : ROOM_OPTIONS.turnServers, + proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS }); this._sendTransport.on( @@ -1958,18 +2107,26 @@ export default class RoomClient }); this._sendTransport.on( - 'produce', ({ kind, rtpParameters, appData }, callback, errback) => + 'produce', async ({ kind, rtpParameters, appData }, callback, errback) => { - this.sendRequest( - 'produce', - { - transportId : this._sendTransport.id, - kind, - rtpParameters, - appData - }) - .then(callback) - .catch(errback); + try + { + // eslint-disable-next-line no-shadow + const { id } = await this.sendRequest( + 'produce', + { + transportId : this._sendTransport.id, + kind, + rtpParameters, + appData + }); + + callback({ id }); + } + catch (error) + { + errback(error); + } }); } @@ -1993,7 +2150,8 @@ export default class RoomClient id, iceParameters, iceCandidates, - dtlsParameters + dtlsParameters, + iceServers : ROOM_OPTIONS.turnServers }); this._recvTransport.on( @@ -2047,7 +2205,8 @@ export default class RoomClient if (this._produce) { if (this._mediasoupDevice.canProduce('audio')) - this.enableMic(); + if (!this._muted) + this.enableMic(); if (joinVideo && this._mediasoupDevice.canProduce('video')) this.enableWebcam(); @@ -2214,7 +2373,7 @@ export default class RoomClient if (this._micProducer) return; - if (!this._mediasoupDevice.canProduce('audio')) + if (this._mediasoupDevice && !this._mediasoupDevice.canProduce('audio')) { logger.error('enableMic() | cannot produce audio'); @@ -2411,19 +2570,45 @@ export default class RoomClient logger.debug('enableScreenSharing() | calling getUserMedia()'); const stream = await this._screenSharing.start({ - width : 1280, - height : 720, - frameRate : 3 + width : 1920, + height : 1080, + frameRate : 5 }); track = stream.getVideoTracks()[0]; - if (this._useSimulcast) + if (this._useSharingSimulcast) { + // If VP9 is the only available video codec then use SVC. + const firstVideoCodec = this._mediasoupDevice + .rtpCapabilities + .codecs + .find((c) => c.kind === 'video'); + + let encodings; + + if (firstVideoCodec.mimeType.toLowerCase() === 'video/vp9') + { + encodings = VIDEO_SVC_ENCODINGS; + } + else + { + if ('simulcastEncodings' in window.config) + { + encodings = window.config.simulcastEncodings + .map((encoding) => ({ ...encoding, dtx: true })); + } + else + { + encodings = VIDEO_SIMULCAST_ENCODINGS + .map((encoding) => ({ ...encoding, dtx: true })); + } + } + this._screenSharingProducer = await this._sendTransport.produce( { track, - encodings : VIDEO_ENCODINGS, + encodings, codecOptions : { videoGoogleStartBitrate : 1000 @@ -2574,10 +2759,28 @@ export default class RoomClient if (this._useSimulcast) { + // If VP9 is the only available video codec then use SVC. + const firstVideoCodec = this._mediasoupDevice + .rtpCapabilities + .codecs + .find((c) => c.kind === 'video'); + + let encodings; + + if (firstVideoCodec.mimeType.toLowerCase() === 'video/vp9') + encodings = VIDEO_KSVC_ENCODINGS; + else + { + if ('simulcastEncodings' in window.config) + encodings = window.config.simulcastEncodings; + else + encodings = VIDEO_SIMULCAST_ENCODINGS; + } + this._webcamProducer = await this._sendTransport.produce( { track, - encodings : VIDEO_ENCODINGS, + encodings, codecOptions : { videoGoogleStartBitrate : 1000 diff --git a/app/src/ScreenShare.js b/app/src/ScreenShare.js index c9336d1..180fe2a 100644 --- a/app/src/ScreenShare.js +++ b/app/src/ScreenShare.js @@ -1,3 +1,70 @@ +import isElectron from 'is-electron'; + +let electron = null; + +if (isElectron()) + electron = window.require('electron'); + +class ElectronScreenShare +{ + constructor() + { + this._stream = null; + } + + start() + { + return Promise.resolve() + .then(() => + { + return electron.desktopCapturer.getSources({ types: [ 'window', 'screen' ] }); + }) + .then((sources) => + { + for (const source of sources) + { + // Currently only getting whole screen + if (source.name === 'Entire Screen') + { + return navigator.mediaDevices.getUserMedia({ + audio : false, + video : + { + mandatory : + { + chromeMediaSource : 'desktop', + chromeMediaSourceId : source.id + } + } + }); + } + } + }) + .then((stream) => + { + this._stream = stream; + + return stream; + }); + } + + stop() + { + if (this._stream instanceof MediaStream === false) + { + return; + } + + this._stream.getTracks().forEach((track) => track.stop()); + this._stream = null; + } + + isScreenShareAvailable() + { + return true; + } +} + class DisplayMediaScreenShare { constructor() @@ -34,12 +101,25 @@ class DisplayMediaScreenShare return true; } - _toConstraints() + _toConstraints(options) { const constraints = { - video : true + video : {} }; + if (isFinite(options.width)) + { + constraints.video.width = options.width; + } + if (isFinite(options.height)) + { + constraints.video.height = options.height; + } + if (isFinite(options.frameRate)) + { + constraints.video.frameRate = options.frameRate; + } + return constraints; } } @@ -131,26 +211,31 @@ export default class ScreenShare { static create(device) { - switch (device.flag) + if (isElectron()) + return new ElectronScreenShare(); + else { - case 'firefox': + switch (device.flag) { - if (device.version < 66.0) - return new FirefoxScreenShare(); - else + case 'firefox': + { + if (device.version < 66.0) + return new FirefoxScreenShare(); + else + return new DisplayMediaScreenShare(); + } + case 'chrome': + { return new DisplayMediaScreenShare(); - } - case 'chrome': - { - return new DisplayMediaScreenShare(); - } - case 'msedge': - { - return new DisplayMediaScreenShare(); - } - default: - { - return new DefaultScreenShare(); + } + case 'msedge': + { + return new DisplayMediaScreenShare(); + } + default: + { + return new DefaultScreenShare(); + } } } } diff --git a/app/src/Spotlights.js b/app/src/Spotlights.js index c683c34..a680f5e 100644 --- a/app/src/Spotlights.js +++ b/app/src/Spotlights.js @@ -198,4 +198,19 @@ export default class Spotlights extends EventEmitter return true; } + + get maxSpotlights() + { + return this._maxSpotlights; + } + + set maxSpotlights(maxSpotlights) + { + const oldMaxSpotlights = this._maxSpotlights; + + this._maxSpotlights = maxSpotlights; + + if (oldMaxSpotlights !== this._maxSpotlights) + this._spotlightsUpdated(); + } } diff --git a/app/src/__tests__/App.spec.js b/app/src/__tests__/App.spec.js new file mode 100644 index 0000000..279788a --- /dev/null +++ b/app/src/__tests__/App.spec.js @@ -0,0 +1,99 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { Route, MemoryRouter } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; +import { createIntl, createIntlCache, RawIntlProvider } from 'react-intl'; +import App from '../components/App'; +import ChooseRoom from '../components/ChooseRoom'; +import RoomContext from '../RoomContext'; + +import configureStore from 'redux-mock-store'; + +const mockStore = configureStore([]); + +let container; + +let store; + +let intl; + +const roomClient = {}; + +beforeEach(() => +{ + container = document.createElement('div'); + + store = mockStore({ + me : { + displayNameInProgress : false, + id : 'jesttester', + loggedIn : false, + loginEnabled : true + }, + room : { + }, + settings : { + displayName : 'Jest Tester' + } + }); + + const cache = createIntlCache(); + + const locale = 'en'; + + intl = createIntl({ + locale, + messages : {} + }, cache); + + document.body.appendChild(container); +}); + +afterEach(() => +{ + document.body.removeChild(container); + container = null; +}); + +describe('', () => +{ + test('renders chooseroom', () => + { + act(() => + { + ReactDOM.render( + + + + + + + + + , + container); + }); + }); +}); + +describe('', () => +{ + test('renders joindialog', () => + { + act(() => + { + ReactDOM.render( + + + + + + + + + , + container); + }); + }); +}); \ No newline at end of file diff --git a/app/src/__tests__/Room.spec.js b/app/src/__tests__/Room.spec.js new file mode 100644 index 0000000..a866e06 --- /dev/null +++ b/app/src/__tests__/Room.spec.js @@ -0,0 +1,126 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import { createIntl, createIntlCache, RawIntlProvider } from 'react-intl'; +import Room from '../components/Room'; +import { SnackbarProvider } from 'notistack'; +import RoomContext from '../RoomContext'; + +import configureStore from 'redux-mock-store'; + +const mockStore = configureStore([]); + +let container; + +let store; + +let intl; + +const roomClient = {}; + +beforeEach(() => +{ + container = document.createElement('div'); + + store = mockStore({ + chat : [], + consumers : {}, + files : {}, + lobbyPeers : {}, + me : { + audioDevices : null, + audioInProgress : false, + canSendMic : false, + canSendWebcam : false, + canShareFiles : false, + canShareScreen : false, + displayNameInProgress : false, + id : 'jesttester', + loggedIn : false, + loginEnabled : true, + picture : null, + raiseHand : false, + raiseHandInProgress : false, + screenShareInProgress : false, + webcamDevices : null, + webcamInProgress : false + }, + notifications : [], + peerVolumes : {}, + peers : {}, + producers : {}, + room : { + accessCode : '', + activeSpeakerId : null, + fullScreenConsumer : null, + inLobby : true, + joinByAccessCode : true, + joined : false, + lockDialogOpen : false, + locked : false, + mode : 'democratic', + name : 'test', + selectedPeerId : null, + settingsOpen : false, + showSettings : false, + signInRequired : false, + spotlights : [], + state : 'connecting', + toolbarsVisible : true, + torrentSupport : false, + windowConsumer : null + }, + settings : { + advancedMode : true, + displayName : 'Jest Tester', + resolution : 'ultra', + selectedAudioDevice : 'default', + selectedWebcam : 'soifjsiajosjfoi' + }, + toolarea : { + currentToolTab : 'chat', + toolAreaOpen : false, + unreadFiles : 0, + unreadMessages : 0 + } + }); + + const cache = createIntlCache(); + + const locale = 'en'; + + intl = createIntl({ + locale, + messages : {} + }, cache); + + document.body.appendChild(container); +}); + +afterEach(() => +{ + document.body.removeChild(container); + container = null; +}); + +describe('', () => +{ + test('renders correctly', () => + { + act(() => + { + ReactDOM.render( + + + + + + + + + , + container); + }); + }); +}); \ No newline at end of file diff --git a/app/src/__tests__/RoomClient.spec.js b/app/src/__tests__/RoomClient.spec.js new file mode 100644 index 0000000..086e6a5 --- /dev/null +++ b/app/src/__tests__/RoomClient.spec.js @@ -0,0 +1,9 @@ +import RoomClient from '../RoomClient'; + +describe('new RoomClient() without paramaters throws Error', () => +{ + test('Matches the snapshot', () => + { + expect(() => new RoomClient()).toThrow(Error); + }); +}); \ No newline at end of file diff --git a/app/src/actions/consumerActions.js b/app/src/actions/consumerActions.js index 0c7bb65..b8460a6 100644 --- a/app/src/actions/consumerActions.js +++ b/app/src/actions/consumerActions.js @@ -34,6 +34,14 @@ export const setConsumerPreferredLayers = (consumerId, spatialLayer, temporalLay payload : { consumerId, spatialLayer, temporalLayer } }); +export const setConsumerPriority = (consumerId, priority) => + { + return { + type : 'SET_CONSUMER_PRIORITY', + payload : { consumerId, priority } + }; + }; + export const setConsumerTrack = (consumerId, track) => ({ type : 'SET_CONSUMER_TRACK', diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 156f638..560c77d 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -1,9 +1,3 @@ -export const setRoomUrl = (url) => - ({ - type : 'SET_ROOM_URL', - payload : { url } - }); - export const setRoomName = (name) => ({ type : 'SET_ROOM_NAME', diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 52110c1..79b5ef2 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -25,4 +25,15 @@ export const setDisplayName = (displayName) => export const toggleAdvancedMode = () => ({ type : 'TOGGLE_ADVANCED_MODE' + }); + +export const togglePermanentTopBar = () => + ({ + type : 'TOGGLE_PERMANENT_TOPBAR' + }); + +export const setLastN = (lastN) => + ({ + type : 'SET_LAST_N', + payload : { lastN } }); \ No newline at end of file diff --git a/app/src/components/App.js b/app/src/components/App.js index e02aff7..0b6b473 100644 --- a/app/src/components/App.js +++ b/app/src/components/App.js @@ -1,4 +1,5 @@ import React, { useEffect, Suspense } from 'react'; +import { useParams } from 'react-router'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import JoinDialog from './JoinDialog'; @@ -13,6 +14,8 @@ const App = (props) => room } = props; + const { id } = useParams(); + useEffect(() => { Room.preload(); @@ -23,7 +26,7 @@ const App = (props) => if (!room.joined) { return ( - + ); } else diff --git a/app/src/components/ChooseRoom.js b/app/src/components/ChooseRoom.js new file mode 100644 index 0000000..ea6b097 --- /dev/null +++ b/app/src/components/ChooseRoom.js @@ -0,0 +1,291 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../RoomContext'; +import isElectron from 'is-electron'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import randomString from 'random-string'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import IconButton from '@material-ui/core/IconButton'; +import AccountCircle from '@material-ui/icons/AccountCircle'; +import Avatar from '@material-ui/core/Avatar'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Tooltip from '@material-ui/core/Tooltip'; +import CookieConsent from 'react-cookie-consent'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogActions from '@material-ui/core/DialogActions'; + +const styles = (theme) => + ({ + root : + { + display : 'flex', + width : '100%', + height : '100%', + backgroundColor : 'var(--background-color)', + backgroundImage : `url(${window.config ? window.config.background : null})`, + backgroundAttachment : 'fixed', + backgroundPosition : 'center', + backgroundSize : 'cover', + backgroundRepeat : 'no-repeat' + }, + dialogTitle : + { + }, + dialogPaper : + { + width : '30vw', + padding : theme.spacing(2), + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + }, + logo : + { + display : 'block', + paddingBottom : '1vh' + }, + loginButton : + { + position : 'absolute', + right : theme.spacing(2), + top : theme.spacing(2), + padding : 0 + }, + largeIcon : + { + fontSize : '2em' + }, + largeAvatar : + { + width : 50, + height : 50 + }, + green : + { + color : 'rgba(0, 153, 0, 1)' + } + }); + +const DialogTitle = withStyles(styles)((props) => +{ + const [ open, setOpen ] = useState(false); + + const intl = useIntl(); + + useEffect(() => + { + const openTimer = setTimeout(() => setOpen(true), 1000); + const closeTimer = setTimeout(() => setOpen(false), 4000); + + return () => + { + clearTimeout(openTimer); + clearTimeout(closeTimer); + }; + }, []); + + const { children, classes, myPicture, onLogin, ...other } = props; + + const handleTooltipClose = () => + { + setOpen(false); + }; + + const handleTooltipOpen = () => + { + setOpen(true); + }; + + return ( + + { window.config && window.config.logo && Logo } + {children} + { window.config && window.config.loginEnabled && + + + { myPicture ? + + : + + } + + + } + + ); +}); + +const DialogContent = withStyles((theme) => ({ + root : + { + padding : theme.spacing(2) + } +}))(MuiDialogContent); + +const DialogActions = withStyles((theme) => ({ + root : + { + margin : 0, + padding : theme.spacing(1) + } +}))(MuiDialogActions); + +const ChooseRoom = ({ + roomClient, + loggedIn, + myPicture, + classes +}) => +{ + const [ roomId, setRoomId ] = + useState(randomString({ length: 8 }).toLowerCase()); + + const intl = useIntl(); + + return ( +
+ + + { + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { window.config && window.config.title ? window.config.title : 'Multiparty meeting' } +
+
+ + + + + + + { + const { value } = event.target; + + setRoomId(value.toLowerCase()); + }} + onBlur={() => + { + if (roomId === '') + setRoomId(randomString({ length: 8 }).toLowerCase()); + }} + fullWidth + /> + + + + + + + { !isElectron() && + + + + } +
+
+ ); +}; + +ChooseRoom.propTypes = +{ + roomClient : PropTypes.any.isRequired, + loginEnabled : PropTypes.bool.isRequired, + loggedIn : PropTypes.bool.isRequired, + myPicture : PropTypes.string, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => +{ + return { + loginEnabled : state.me.loginEnabled, + loggedIn : state.me.loggedIn, + myPicture : state.me.picture + }; +}; + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me.loginEnabled === next.me.loginEnabled && + prev.me.loggedIn === next.me.loggedIn && + prev.me.picture === next.me.picture + ); + } + } +)(withStyles(styles)(ChooseRoom))); \ No newline at end of file diff --git a/app/src/components/Containers/HiddenPeers.js b/app/src/components/Containers/HiddenPeers.js deleted file mode 100644 index 49b5462..0000000 --- a/app/src/components/Containers/HiddenPeers.js +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import { withStyles } from '@material-ui/core/styles'; -import { FormattedMessage } from 'react-intl'; -import * as toolareaActions from '../../actions/toolareaActions'; -import BuddyImage from '../../images/buddy.svg'; - -const styles = () => - ({ - root : - { - width : '12vmin', - height : '9vmin', - position : 'absolute', - bottom : '3%', - right : '3%', - color : 'rgba(170, 170, 170, 1)', - cursor : 'pointer', - backgroundImage : `url(${BuddyImage})`, - backgroundColor : 'rgba(42, 75, 88, 1)', - backgroundPosition : 'bottom', - backgroundSize : 'auto 85%', - backgroundRepeat : 'no-repeat', - border : 'var(--peer-border)', - boxShadow : 'var(--peer-shadow)', - textAlign : 'center', - verticalAlign : 'middle', - lineHeight : '1.8vmin', - fontSize : '1.7vmin', - fontWeight : 'bolder', - animation : 'none', - '&.pulse' : - { - animation : 'pulse 0.5s' - } - }, - '@keyframes pulse' : - { - '0%' : - { - transform : 'scale3d(1, 1, 1)' - }, - '50%' : - { - transform : 'scale3d(1.2, 1.2, 1.2)' - }, - '100%' : - { - transform : 'scale3d(1, 1, 1)' - } - } - }); - -class HiddenPeers extends React.PureComponent -{ - constructor(props) - { - super(props); - this.state = { className: '' }; - } - - componentDidUpdate(prevProps) - { - const { hiddenPeersCount } = this.props; - - if (hiddenPeersCount !== prevProps.hiddenPeersCount) - { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ className: 'pulse' }, () => - { - if (this.timeout) - { - clearTimeout(this.timeout); - } - - this.timeout = setTimeout(() => - { - this.setState({ className: '' }); - }, 500); - }); - } - } - - render() - { - const { - hiddenPeersCount, - openUsersTab, - classes - } = this.props; - - return ( -
openUsersTab()} - > -

- +{hiddenPeersCount}
- -

-
- ); - } -} - -HiddenPeers.propTypes = -{ - hiddenPeersCount : PropTypes.number, - openUsersTab : PropTypes.func.isRequired, - classes : PropTypes.object.isRequired -}; - -const mapDispatchToProps = (dispatch) => -{ - return { - openUsersTab : () => - { - dispatch(toolareaActions.openToolArea()); - dispatch(toolareaActions.setToolTab('users')); - } - }; -}; - -export default connect( - null, - mapDispatchToProps -)(withStyles(styles)(HiddenPeers)); diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 37e0068..9a2d21e 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -31,21 +31,28 @@ const styles = (theme) => backgroundPosition : 'bottom', backgroundSize : 'auto 85%', backgroundRepeat : 'no-repeat', - '&.webcam' : + '&.hover' : + { + boxShadow : '0px 1px 3px rgba(0, 0, 0, 0.05) inset, 0px 0px 8px rgba(82, 168, 236, 0.9)' + }, + '&.active-speaker' : + { + // transition : 'filter .2s', + // filter : 'grayscale(0)', + borderColor : 'var(--active-speaker-border-color)' + }, + '&:not(.active-speaker):not(.screen)' : + { + // transition : 'filter 10s', + // filter : 'grayscale(0.75)' + }, + '&.webcam' : { order : 1 }, '&.screen' : { order : 2 - }, - '&.hover' : - { - boxShadow : '0px 1px 3px rgba(0, 0, 0, 0.05) inset, 0px 0px 8px rgba(82, 168, 236, 0.9)' - }, - '&.active-speaker' : - { - borderColor : 'var(--active-speaker-border-color)' } }, fab : diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index a303788..79eac19 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -31,21 +31,28 @@ const styles = (theme) => backgroundPosition : 'bottom', backgroundSize : 'auto 85%', backgroundRepeat : 'no-repeat', - '&.webcam' : + '&.hover' : + { + boxShadow : '0px 1px 3px rgba(0, 0, 0, 0.05) inset, 0px 0px 8px rgba(82, 168, 236, 0.9)' + }, + '&.active-speaker' : + { + // transition : 'filter .2s', + // filter : 'grayscale(0)', + borderColor : 'var(--active-speaker-border-color)' + }, + '&:not(.active-speaker):not(.screen)' : + { + // transition : 'filter 10s', + // filter : 'grayscale(0.75)' + }, + '&.webcam' : { order : 4 }, '&.screen' : { order : 3 - }, - '&.hover' : - { - boxShadow : '0px 1px 3px rgba(0, 0, 0, 0.05) inset, 0px 0px 8px rgba(82, 168, 236, 0.9)' - }, - '&.active-speaker' : - { - borderColor : 'var(--active-speaker-border-color)' } }, fab : @@ -145,16 +152,6 @@ const Peer = (props) => !screenConsumer.remotelyPaused ); - let videoProfile; - - if (webcamConsumer) - videoProfile = webcamConsumer.profile; - - let screenProfile; - - if (screenConsumer) - screenProfile = screenConsumer.profile; - const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); const rootStyle = @@ -325,11 +322,27 @@ const Peer = (props) => peer={peer} displayName={peer.displayName} showPeerInfo + consumerSpatialLayers={webcamConsumer ? webcamConsumer.spatialLayers : null} + consumerTemporalLayers={webcamConsumer ? webcamConsumer.temporalLayers : null} + consumerCurrentSpatialLayer={ + webcamConsumer ? webcamConsumer.currentSpatialLayer : null + } + consumerCurrentTemporalLayer={ + webcamConsumer ? webcamConsumer.currentTemporalLayer : null + } + consumerPreferredSpatialLayer={ + webcamConsumer ? webcamConsumer.preferredSpatialLayer : null + } + consumerPreferredTemporalLayer={ + webcamConsumer ? webcamConsumer.preferredTemporalLayer : null + } + videoMultiLayer={webcamConsumer && webcamConsumer.type !== 'simple'} videoTrack={webcamConsumer && webcamConsumer.track} videoVisible={videoVisible} - videoProfile={videoProfile} audioCodec={micConsumer && micConsumer.codec} videoCodec={webcamConsumer && webcamConsumer.codec} + audioScore={micConsumer ? micConsumer.score : null} + videoScore={webcamConsumer ? webcamConsumer.score : null} > @@ -360,109 +373,124 @@ const Peer = (props) => }} style={rootStyle} > - { !screenVisible && -
-

- -

-
- } +
+ { !screenVisible && +
+

+ +

+
+ } +
setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + setHover(true); + }} + onTouchEnd={() => + { - { screenVisible && -
-
setHover(true)} - onMouseOut={() => setHover(false)} - onTouchStart={() => + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => { - if (touchTimeout) - clearTimeout(touchTimeout); - - setHover(true); - }} - onTouchEnd={() => - { - - if (touchTimeout) - clearTimeout(touchTimeout); - - touchTimeout = setTimeout(() => - { - setHover(false); - }, 2000); - }} - > - { !smallScreen && - -
- - { - toggleConsumerWindow(screenConsumer); - }} - > - - -
-
- } - + setHover(false); + }, 2000); + }} + > + { !smallScreen &&
{ - toggleConsumerFullscreen(screenConsumer); + toggleConsumerWindow(screenConsumer); }} > - +
-
- + } + + +
+ + { + toggleConsumerFullscreen(screenConsumer); + }} + > + + +
+
- } + +
} @@ -488,17 +516,17 @@ Peer.propTypes = theme : PropTypes.object.isRequired }; -const makeMapStateToProps = (initialState, props) => +const makeMapStateToProps = (initialState, { id }) => { const getPeerConsumers = makePeerConsumerSelector(); const mapStateToProps = (state) => { return { - peer : state.peers[props.id], - ...getPeerConsumers(state, props), + peer : state.peers[id], + ...getPeerConsumers(state, id), windowConsumer : state.room.windowConsumer, - activeSpeaker : props.id === state.room.activeSpeakerId + activeSpeaker : id === state.room.activeSpeakerId }; }; diff --git a/app/src/components/Containers/SpeakerPeer.js b/app/src/components/Containers/SpeakerPeer.js index 011b93c..fa3300b 100644 --- a/app/src/components/Containers/SpeakerPeer.js +++ b/app/src/components/Containers/SpeakerPeer.js @@ -190,13 +190,13 @@ SpeakerPeer.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state, props) => +const mapStateToProps = (state, { id }) => { const getPeerConsumers = makePeerConsumerSelector(); return { - peer : state.peers[props.id], - ...getPeerConsumers(state, props) + peer : state.peers[id], + ...getPeerConsumers(state, id) }; }; diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 21c09b0..6ef8dae 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -2,7 +2,8 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { - lobbyPeersKeySelector + lobbyPeersKeySelector, + peersLengthSelector } from '../Selectors'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; @@ -22,6 +23,7 @@ import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; import SettingsIcon from '@material-ui/icons/Settings'; import SecurityIcon from '@material-ui/icons/Security'; +import PeopleIcon from '@material-ui/icons/People'; import LockIcon from '@material-ui/icons/Lock'; import LockOpenIcon from '@material-ui/icons/LockOpen'; import Button from '@material-ui/core/Button'; @@ -43,6 +45,10 @@ const styles = (theme) => display : 'block' } }, + divider : + { + marginLeft : theme.spacing(3), + }, show : { opacity : 1, @@ -115,7 +121,9 @@ const TopBar = (props) => const { roomClient, room, + peersLength, lobbyPeers, + permanentTopBar, myPicture, loggedIn, loginEnabled, @@ -125,6 +133,7 @@ const TopBar = (props) => setSettingsOpen, setLockDialogOpen, toggleToolArea, + openUsersTab, unread, classes } = props; @@ -165,12 +174,13 @@ const TopBar = (props) => return ( toggleToolArea()} > id : 'label.openDrawer', defaultMessage : 'Open drawer' })} - onClick={() => toggleToolArea()} className={classes.menuButton} > - { window.config.logo && Logo } + { window.config && window.config.logo && Logo } - { window.config.title } + { window.config && window.config.title ? window.config.title : 'Multiparty meeting' }
+ { fullscreenEnabled && + + + { fullscreen ? + + : + + } + + + } + + openUsersTab()} + > + + + + + + + setSettingsOpen(!room.settingsOpen)} + > + + + } - { fullscreenEnabled && - - - { fullscreen ? - - : - - } - - - } - - setSettingsOpen(!room.settingsOpen)} - > - - - { loginEnabled && } +
); @@ -348,6 +355,7 @@ JoinDialog.propTypes = { roomClient : PropTypes.any.isRequired, room : PropTypes.object.isRequired, + roomId : PropTypes.string.isRequired, displayName : PropTypes.string.isRequired, displayNameInProgress : PropTypes.bool.isRequired, loginEnabled : PropTypes.bool.isRequired, diff --git a/app/src/components/MeetingDrawer/Chat/ChatInput.js b/app/src/components/MeetingDrawer/Chat/ChatInput.js index f84af96..5be44e9 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatInput.js +++ b/app/src/components/MeetingDrawer/Chat/ChatInput.js @@ -93,7 +93,7 @@ const ChatInput = (props) => { if (message && message !== '') { - const sendMessage = this.createNewMessage(message, 'response', displayName, picture); + const sendMessage = createNewMessage(message, 'response', displayName, picture); roomClient.sendChatMessage(sendMessage); diff --git a/app/src/components/MeetingDrawer/Chat/Message.js b/app/src/components/MeetingDrawer/Chat/Message.js index 85a749d..a60c245 100644 --- a/app/src/components/MeetingDrawer/Chat/Message.js +++ b/app/src/components/MeetingDrawer/Chat/Message.js @@ -14,7 +14,7 @@ linkRenderer.link = (href, title, text) => title = title ? title : href; text = text ? text : href; - return (`${ text }`); + return `${ text }`; }; const styles = (theme) => @@ -81,7 +81,11 @@ const Message = (props) => marked.parse( text, { renderer: linkRenderer } - ) + ), + { + ALLOWED_TAGS : [ 'a' ], + ALLOWED_ATTR : [ 'href', 'target', 'title' ] + } ) }} /> {self ? 'Me' : name} - {time} diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index cd19e9e..9f74e65 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -6,6 +6,8 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import * as appPropTypes from '../../appPropTypes'; import { withRoomContext } from '../../../RoomContext'; +import { useIntl } from 'react-intl'; +import IconButton from '@material-ui/core/IconButton'; import MicIcon from '@material-ui/icons/Mic'; import MicOffIcon from '@material-ui/icons/MicOff'; import ScreenIcon from '@material-ui/icons/ScreenShare'; @@ -128,6 +130,8 @@ const styles = (theme) => const ListPeer = (props) => { + const intl = useIntl(); + const { roomClient, peer, @@ -174,47 +178,49 @@ const ListPeer = (props) => {children}
{ screenConsumer && -
- { - e.stopPropagation(); - screenVisible ? - roomClient.modifyPeerConsumer(peer.id, 'screen', true) : - roomClient.modifyPeerConsumer(peer.id, 'screen', false); - }} + { + e.stopPropagation(); + screenVisible ? + roomClient.modifyPeerConsumer(peer.id, 'screen', true) : + roomClient.modifyPeerConsumer(peer.id, 'screen', false); + }} > { screenVisible ? : } -
+ } -
- { - e.stopPropagation(); - micEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'mic', true) : - roomClient.modifyPeerConsumer(peer.id, 'mic', false); - }} + { + e.stopPropagation(); + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} > { micEnabled ? : } -
+
); @@ -232,15 +238,15 @@ ListPeer.propTypes = classes : PropTypes.object.isRequired }; -const makeMapStateToProps = (initialState, props) => +const makeMapStateToProps = (initialState, { id }) => { const getPeerConsumers = makePeerConsumerSelector(); const mapStateToProps = (state) => { return { - peer : state.peers[props.id], - ...getPeerConsumers(state, props) + peer : state.peers[id], + ...getPeerConsumers(state, id) }; }; diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index 0cb9c2b..3313d2b 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -100,16 +100,16 @@ class ParticipantList extends React.PureComponent defaultMessage='Participants in Spotlight' /> - { spotlightPeers.map((peer) => ( + { spotlightPeers.map((peerId) => (
  • roomClient.setSelectedPeer(peer.id)} + onClick={() => roomClient.setSelectedPeer(peerId)} > - - + +
  • ))} diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js index ca735bd..550dda0 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -2,16 +2,13 @@ import React from 'react'; import { connect } from 'react-redux'; import { spotlightPeersSelector, - peersLengthSelector, - videoBoxesSelector, - spotlightsLengthSelector + videoBoxesSelector } from '../Selectors'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import Peer from '../Containers/Peer'; import Me from '../Containers/Me'; -import HiddenPeers from '../Containers/HiddenPeers'; const RATIO = 1.334; const PADDING_V = 50; @@ -71,7 +68,7 @@ class Democratic extends React.PureComponent const width = this.peersRef.current.clientWidth - PADDING_H; const height = this.peersRef.current.clientHeight - - (this.props.toolbarsVisible ? PADDING_V : PADDING_H); + (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING_V : PADDING_H); let x, y, space; @@ -91,11 +88,11 @@ class Democratic extends React.PureComponent break; } } - if (Math.ceil(this.state.peerWidth) !== Math.ceil(0.9 * x)) + if (Math.ceil(this.state.peerWidth) !== Math.ceil(0.94 * x)) { this.setState({ - peerWidth : 0.95 * x, - peerHeight : 0.95 * y + peerWidth : 0.94 * x, + peerHeight : 0.94 * y }); } }; @@ -130,10 +127,9 @@ class Democratic extends React.PureComponent { const { advancedMode, - peersLength, spotlightsPeers, - spotlightsLength, toolbarsVisible, + permanentTopBar, classes } = this.props; @@ -147,7 +143,7 @@ class Democratic extends React.PureComponent
    @@ -160,19 +156,14 @@ class Democratic extends React.PureComponent { return ( ); })} - { spotlightsLength < peersLength && - - }
    ); } @@ -181,22 +172,20 @@ class Democratic extends React.PureComponent Democratic.propTypes = { advancedMode : PropTypes.bool, - peersLength : PropTypes.number, boxes : PropTypes.number, - spotlightsLength : PropTypes.number, spotlightsPeers : PropTypes.array.isRequired, toolbarsVisible : PropTypes.bool.isRequired, + permanentTopBar : PropTypes.bool, classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - peersLength : peersLengthSelector(state), - boxes : videoBoxesSelector(state), - spotlightsPeers : spotlightPeersSelector(state), - spotlightsLength : spotlightsLengthSelector(state), - toolbarsVisible : state.room.toolbarsVisible + boxes : videoBoxesSelector(state), + spotlightsPeers : spotlightPeersSelector(state), + toolbarsVisible : state.room.toolbarsVisible, + permanentTopBar : state.settings.permanentTopBar }; }; @@ -212,7 +201,8 @@ export default connect( prev.producers === next.producers && prev.consumers === next.consumers && prev.room.spotlights === next.room.spotlights && - prev.room.toolbarsVisible === next.room.toolbarsVisible + prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.settings.permanentTopBar === next.settings.permanentTopBar ); } } diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index fde6515..503948e 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -4,14 +4,12 @@ import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import classnames from 'classnames'; import { - spotlightsLengthSelector, videoBoxesSelector } from '../Selectors'; import { withRoomContext } from '../../RoomContext'; import Me from '../Containers/Me'; import Peer from '../Containers/Peer'; import SpeakerPeer from '../Containers/SpeakerPeer'; -import HiddenPeers from '../Containers/HiddenPeers'; import Grid from '@material-ui/core/Grid'; const styles = () => @@ -207,7 +205,6 @@ class Filmstrip extends React.PureComponent myId, advancedMode, spotlights, - spotlightsLength, classes } = this.props; @@ -284,12 +281,6 @@ class Filmstrip extends React.PureComponent })}
    - - { spotlightsLength - } ); } @@ -303,7 +294,6 @@ Filmstrip.propTypes = { consumers : PropTypes.object.isRequired, myId : PropTypes.string.isRequired, selectedPeerId : PropTypes.string, - spotlightsLength : PropTypes.number, spotlights : PropTypes.array.isRequired, boxes : PropTypes.number, classes : PropTypes.object.isRequired @@ -318,8 +308,7 @@ const mapStateToProps = (state) => consumers : state.consumers, myId : state.me.id, spotlights : state.room.spotlights, - spotlightsLength : spotlightsLengthSelector(state), - boxes : videoBoxesSelector(state), + boxes : videoBoxesSelector(state) }; }; diff --git a/app/src/components/Room.js b/app/src/components/Room.js index 23d022a..59b0665 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import * as appPropTypes from './appPropTypes'; import { withStyles } from '@material-ui/core/styles'; +import isElectron from 'is-electron'; import * as roomActions from '../actions/roomActions'; import * as toolareaActions from '../actions/toolareaActions'; import { idle } from '../utils'; @@ -33,7 +34,7 @@ const styles = (theme) => width : '100%', height : '100%', backgroundColor : 'var(--background-color)', - backgroundImage : `url(${window.config.background})`, + backgroundImage : `url(${window.config ? window.config.background : null})`, backgroundAttachment : 'fixed', backgroundPosition : 'center', backgroundSize : 'cover', @@ -152,12 +153,21 @@ class Room extends React.PureComponent return (
    - - - + { !isElectron() && + + } + > + + + } @@ -194,9 +204,13 @@ class Room extends React.PureComponent - + { room.lockDialogOpen && + + } - + { room.settingsOpen && + + }
    ); } diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index d3e293d..7a9cbfc 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -5,8 +5,8 @@ const consumersSelect = (state) => state.consumers; const spotlightsSelector = (state) => state.room.spotlights; const peersSelector = (state) => state.peers; const lobbyPeersSelector = (state) => state.lobbyPeers; -const getPeerConsumers = (state, props) => - (state.peers[props.id] ? state.peers[props.id].consumers : null); +const getPeerConsumers = (state, id) => + (state.peers[id] ? state.peers[id].consumers : null); const getAllConsumers = (state) => state.consumers; const peersKeySelector = createSelector( peersSelector, @@ -70,15 +70,8 @@ export const spotlightsLengthSelector = createSelector( export const spotlightPeersSelector = createSelector( spotlightsSelector, - peersSelector, - (spotlights, peers) => - spotlights.reduce((result, peerId) => - { - if (peers[peerId]) - result.push(peers[peerId]); - - return result; - }, []) + peersKeySelector, + (spotlights, peers) => peers.filter((peerId) => spotlights.includes(peerId)) ); export const peersLengthSelector = createSelector( diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 99dca97..4817efe 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -59,6 +59,7 @@ const Settings = ({ me, settings, onToggleAdvancedMode, + onTogglePermanentTopBar, handleCloseSettings, handleChangeMode, classes @@ -296,6 +297,48 @@ const Settings = ({ defaultMessage : 'Advanced mode' })} /> + { settings.advancedMode && + +
    + + + + + + +
    + } + label={intl.formatMessage({ + id : 'settings.permanentTopBar', + defaultMessage : 'Permanent top bar' + })} + /> +
    + }