diff --git a/CHANGELOG.md b/CHANGELOG.md index 471b71f..28984be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 3.3 +* Add: Rooms now scale across cores +* Add: Permissions and roles. Users can now have different roles (moderator, admin etc.) that give different permissions. +* Add: TURN API or fallback TURN server +* Add: Configurable room size limit +* Add: Prometheus monitoring support +* Add: Possible to share several videos (ex: 2 webcams) +* Add: Configurable audio settings (echocancellation etc.) +* Add: Configurable audio output device (in supported browsers) +* Add: Audio auto mute/unmute based on volume +* Add: Handle unsupported browsers properly +* Add: Lots of appearence settings +* Add: Side drawer can now stay permanently open +* Add: Move control buttons to separate control bar +* Add: Can now "raise hand" +* Add: Screen sharing in Safari 13+, Opera and Edge +* Add: Extended advanced info about network in client +* Add: Configurable screen sharing frame rate +* Add: Help and About dialogs +* Add: More keyboard shortcuts +* Add: Quality indicator on videos +* Add: More translations +* Fix: Various UI fixes and improvements +* Fix: Better audio/video device handling +* Fix: Update keyboard shortcut handling +* Fix: Authentication for load balanced scenarios +* Fix: Signaling when entering lobby +* Fix: Signaling timeouts and retries +* Fix: Filesharing fixes (sharing same file twice, etc.) +* Fix: Better handling of hark +* Fix: Use applyContraints instead of restarting producers +* Fix: Now handles reconnects properly if client loses connection +* Fix: Rotating devices don't show rotated videos +* Fix: Various fixes to client authentication + ## 3.2.1 * Fix: permananent top bar by default diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d5c8efc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +Source code contributions should pass static code analysis as performed by `npm run lint` in `server` and `app` respectively. diff --git a/HAproxy.md b/HAproxy.md index b738097..485e11e 100644 --- a/HAproxy.md +++ b/HAproxy.md @@ -62,14 +62,6 @@ OR ## Configure multiparty-meeting servers -### App config - -mm/configs/app/config.js - -``` js -multipartyServer : 'meet.example.com', -``` - ### Server config mm/configs/server/config.js diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b3d9d19 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 GÉANT Association + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 433d924..23784c3 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ If you want the ansible approach, you can find ansible role [here](https://githu ## Manual installation * Prerequisites: -Currently multiparty-meeting will only run on nodejs v10.* +Currently multiparty-meeting will only run on nodejs v13.x To install see here [here](https://github.com/nodesource/distributions/blob/master/README.md#debinstall). ```bash -$ sudo apt install npm build-essential redis +$ sudo apt install git npm build-essential redis ``` * Clone the project: @@ -113,7 +113,7 @@ To integrate with an LMS (e.g. Moodle), have a look at [LTI](LTI/LTI.md). ## TURN configuration -* You need an additional [TURN](https://github.com/coturn/coturn)-server for clients located behind restrictive firewalls! Add your server and credentials to `app/public/config/config.js` +* You need an additional [TURN](https://github.com/coturn/coturn)-server for clients located behind restrictive firewalls! Add your server and credentials to `server/config/config.js` ## Community-driven support @@ -134,7 +134,7 @@ This started as a fork of the [work](https://github.com/versatica/mediasoup-demo ## License -MIT +MIT License (see `LICENSE.md`) 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. diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..86c714e --- /dev/null +++ b/app/.env @@ -0,0 +1,2 @@ +REACT_APP_VERSION=$npm_package_version +REACT_APP_NAME=$npm_package_name \ No newline at end of file diff --git a/app/.eslintrc.json b/app/.eslintrc.json index 44a50ba..d052d77 100644 --- a/app/.eslintrc.json +++ b/app/.eslintrc.json @@ -159,6 +159,12 @@ "no-inner-declarations": 2, "no-invalid-regexp": 2, "no-irregular-whitespace": 2, + "no-trailing-spaces": [ + "error", + { + "ignoreComments": true + } + ], "no-lonely-if": 2, "no-mixed-operators": 2, "no-mixed-spaces-and-tabs": 2, diff --git a/app/package.json b/app/package.json index b82d255..eeb379b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "multiparty-meeting", - "version": "3.2.1", + "version": "3.3.0", "private": true, "description": "multiparty meeting service", "author": "Håvar Aambø Fosstveit ", @@ -20,17 +20,19 @@ "hark": "^1.2.3", "is-electron": "^2.2.0", "marked": "^0.8.0", - "mediasoup-client": "^3.5.4", + "mediasoup-client": "^3.6.5", "notistack": "^0.9.5", "prop-types": "^15.7.2", "random-string": "^0.2.0", "react": "^16.10.2", "react-cookie-consent": "^2.5.0", "react-dom": "^16.10.2", + "react-flip-toolkit": "^7.0.9", "react-intl": "^3.4.0", "react-redux": "^7.1.1", "react-router-dom": "^5.1.2", - "react-scripts": "3.0.1", + "react-scripts": "3.4.1", + "react-wakelock-react16": "0.0.7", "redux": "^4.0.4", "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", @@ -48,7 +50,8 @@ "test": "react-scripts test", "eject": "react-scripts eject", "electron": "electron --no-sandbox .", - "dev": "nf start -p 3000" + "dev": "nf start -p 3000", + "lint": "eslint -c .eslintrc.json --ext .js src" }, "browserslist": [ ">0.2%", @@ -58,6 +61,7 @@ ], "devDependencies": { "electron": "^7.1.1", + "eslint-plugin-react": "^7.19.0", "foreman": "^3.0.1", "redux-mock-store": "^1.5.3" } diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 6d78565..2bb5fb5 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -1,52 +1,148 @@ // eslint-disable-next-line var config = { - loginEnabled : false, - developmentPort : 3443, - productionPort : 443, - multipartyServer : 'letsmeet.no', - turnServers : [ - { - urls : [ - 'turn:turn.example.com:443?transport=tcp' - ], - username : 'example', - credential : 'example' - } - ], + loginEnabled : false, + developmentPort : 3443, + productionPort : 443, + /** - * If defaultResolution is set, it will override user settings when joining: + * Supported browsers version + * in bowser satisfy format. + * See more: + * https://www.npmjs.com/package/bowser#filtering-browsers + * Otherwise you got a unsupported browser page + */ + supportedBrowsers : + { + 'windows' : { + 'internet explorer' : '>12', + 'microsoft edge' : '>18' + }, + 'safari' : '>12', + 'firefox' : '>=60', + 'chrome' : '>=74', + 'chromium' : '>=74', + 'opera' : '>=62', + 'samsung internet for android' : '>=11.1.1.52' + }, + + /** + * Resolutions: + * * low ~ 320x240 * medium ~ 640x480 * high ~ 1280x720 * veryhigh ~ 1920x1080 * ultra ~ 3840x2560 + * **/ - defaultResolution : 'medium', + + /** + * Frame rates: + * + * 1, 5, 10, 15, 20, 25, 30 + * + **/ + + defaultResolution : 'medium', + defaultFrameRate : 15, + defaultScreenResolution : 'veryhigh', + defaultScreenSharingFrameRate : 5, // Enable or disable simulcast for webcam video - simulcast : true, + simulcast : true, // Enable or disable simulcast for screen sharing video - simulcastSharing : false, + simulcastSharing : false, // Simulcast encoding layers and levels - simulcastEncodings : + simulcastEncodings : [ { scaleResolutionDownBy: 4 }, { scaleResolutionDownBy: 2 }, { scaleResolutionDownBy: 1 } ], + + /** + * Alternative simulcast setting: + * [ + * { maxBitRate: 50000 }, + * { maxBitRate: 1000000 }, + * { maxBitRate: 4800000 } + *], + **/ + + /** + * White listing browsers that support audio output device selection. + * It is not yet fully implemented in Firefox. + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512 + */ + audioOutputSupportedBrowsers : + [ + 'chrome', + 'opera' + ], // Socket.io request timeout - requestTimeout : 10000, - transportOptions : + requestTimeout : 20000, + requestRetries : 3, + transportOptions : { tcp : true }, - lastN : 4, - mobileLastN : 1, - background : 'images/background.jpg', + defaultAudio : + { + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : true, + noiseSuppression : true, + voiceActivityMute : false, + sampleSize : 16 + }, + + /** + * Set max number participants in one room that join + * unmuted. Next participant will join automatically muted + * Default value is 4 + * + * Set it to 0 to auto mute all, + * Set it to negative (-1) to never automatically auto mute + * but use it with caution + * full mesh audio strongly decrease room capacity! + */ + autoMuteThreshold : 4, + background : 'images/background.jpg', + defaultLayout : 'democratic', // democratic, filmstrip + // If true, will show media control buttons in separate + // control bar, not in the ME container. + buttonControlBar : false, + // If false, will push videos away to make room for side + // drawer. If true, will overlay side drawer over videos + drawerOverlayed : true, + // Position of notifications + notificationPosition : 'right', + // Timeout for autohiding topbar and button control bar + hideTimeout : 3000, + // max number of participant that will be visible in + // as speaker + lastN : 4, + mobileLastN : 1, + // Highest number of lastN the user can select manually in + // userinteface + maxLastN : 5, + // If truthy, users can NOT change number of speakers visible + lockLastN : false, // Add file and uncomment for adding logo to appbar // logo : 'images/logo.svg', - title : 'Multiparty meeting', - theme : + title : 'Multiparty meeting', + // Service & Support URL + // if not set then not displayed on the about modals + supportUrl : 'https://support.example.com', + // Privacy and dataprotection URL or path + // by default privacy/privacy.html + // that is a placeholder for your policies + // + // but an external url could be also used here + privacyUrl : 'static/privacy.html', + theme : { palette : { diff --git a/app/public/index.html b/app/public/index.html index 86b1003..17a531d 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -16,6 +16,44 @@ Multiparty Meeting + + + diff --git a/app/public/privacy/privacy.html b/app/public/privacy/privacy.html new file mode 100644 index 0000000..70ab6ab --- /dev/null +++ b/app/public/privacy/privacy.html @@ -0,0 +1,18 @@ + + + + + + Placeholder for Privacy Statement / Policy, AUP + + + +

Privacy Statement

+

Privacy Policy

+ +

Acceptable use policy (AUP)

+ + \ No newline at end of file diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 97c8898..7f5a6ce 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1,6 +1,7 @@ import Logger from './Logger'; import hark from 'hark'; import { getSignalingUrl } from './urlFactory'; +import { SocketTimeoutError } from './utils'; import * as requestActions from './actions/requestActions'; import * as meActions from './actions/meActions'; import * as roomActions from './actions/roomActions'; @@ -13,6 +14,7 @@ import * as lobbyPeerActions from './actions/lobbyPeerActions'; import * as consumerActions from './actions/consumerActions'; import * as producerActions from './actions/producerActions'; import * as notificationActions from './actions/notificationActions'; +import * as transportActions from './actions/transportActions'; let createTorrent; @@ -28,22 +30,18 @@ let ScreenShare; let Spotlights; -let turnServers, - requestTimeout, +let requestTimeout, transportOptions, lastN, - mobileLastN, - defaultResolution; + mobileLastN; if (process.env.NODE_ENV !== 'test') { ({ - turnServers, requestTimeout, transportOptions, lastN, - mobileLastN, - defaultResolution + mobileLastN } = window.config); } @@ -52,8 +50,7 @@ const logger = new Logger('RoomClient'); const ROOM_OPTIONS = { requestTimeout : requestTimeout, - transportOptions : transportOptions, - turnServers : turnServers + transportOptions : transportOptions }; const VIDEO_CONSTRAINS = @@ -127,7 +124,16 @@ export default class RoomClient } constructor( - { peerId, accessCode, device, useSimulcast, useSharingSimulcast, produce, forceTcp, displayName, muted } = {}) + { + peerId, + accessCode, + device, + produce, + forceTcp, + displayName, + muted, + basePath + } = {}) { if (!peerId) throw new Error('Missing peerId'); @@ -135,8 +141,8 @@ export default class RoomClient throw new Error('Missing device'); logger.debug( - 'constructor() [peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", forceTcp: "%s", displayName ""]', - peerId, device.flag, useSimulcast, produce, forceTcp, displayName); + 'constructor() [peerId: "%s", device: "%s", produce: "%s", forceTcp: "%s", displayName ""]', + peerId, device.flag, produce, forceTcp, displayName); this._signalingUrl = null; @@ -149,21 +155,26 @@ export default class RoomClient // Whether we force TCP this._forceTcp = forceTcp; + // URL basepath + this._basePath = basePath; + // Use displayName if (displayName) store.dispatch(settingsActions.setDisplayName(displayName)); + this._tracker = 'wss://tracker.lab.vvc.niif.hu:443'; + // Torrent support this._torrentSupport = null; // Whether simulcast should be used. - this._useSimulcast = useSimulcast; + this._useSimulcast = false; if ('simulcast' in window.config) this._useSimulcast = window.config.simulcast; // Whether simulcast should be used for sharing - this._useSharingSimulcast = useSharingSimulcast; + this._useSharingSimulcast = false; if ('simulcastSharing' in window.config) this._useSharingSimulcast = window.config.simulcastSharing; @@ -192,14 +203,14 @@ export default class RoomClient // @type {mediasoupClient.Device} this._mediasoupDevice = null; + // Put the browser info into state + store.dispatch(meActions.setBrowser(device)); + // Our WebTorrent client this._webTorrent = null; - if (defaultResolution) - store.dispatch(settingsActions.setVideoResolution(defaultResolution)); - // Max spotlights - if (device.bowser.getPlatformType() === 'desktop') + if (device.platform === 'desktop') this._maxSpotlights = lastN; else this._maxSpotlights = mobileLastN; @@ -222,15 +233,23 @@ export default class RoomClient // Local mic hark this._hark = null; + // Local MediaStream for hark + this._harkStream = null; + // Local webcam mediasoup Producer. this._webcamProducer = null; + // Extra videos being produced + this._extraVideoProducers = new Map(); + // Map of webcam MediaDeviceInfos indexed by deviceId. // @type {Map} this._webcams = {}; this._audioDevices = {}; + this._audioOutputDevices = {}; + // mediasoup Consumers. // @type {Map} this._consumers = new Map(); @@ -269,9 +288,10 @@ export default class RoomClient _startKeyListener() { - // Add keypress event listener on document - document.addEventListener('keypress', (event) => + // Add keydown event listener on document + document.addEventListener('keydown', (event) => { + if (event.repeat) return; const key = String.fromCharCode(event.which); const source = event.target; @@ -280,11 +300,27 @@ export default class RoomClient if (exclude.indexOf(source.tagName.toLowerCase()) === -1) { - logger.debug('keyPress() [key:"%s"]', key); + logger.debug('keyDown() [key:"%s"]', key); switch (key) { - case 'a': // Activate advanced mode + case String.fromCharCode(37): + { + const newPeerId = this._spotlights.getPrevAsSelected( + store.getState().room.selectedPeerId); + + if (newPeerId) this.setSelectedPeer(newPeerId); + break; + } + case String.fromCharCode(39): + { + const newPeerId = this._spotlights.getNextAsSelected( + store.getState().room.selectedPeerId); + + if (newPeerId) this.setSelectedPeer(newPeerId); + break; + } + case 'A': // Activate advanced mode { store.dispatch(settingsActions.toggleAdvancedMode()); store.dispatch(requestActions.notify( @@ -323,8 +359,19 @@ export default class RoomClient break; } - case ' ': - case 'm': // Toggle microphone + case ' ': // Push To Talk start + { + if (this._micProducer) + { + if (this._micProducer.paused) + { + this.unmuteMic(); + } + } + + break; + } + case 'M': // Toggle microphone { if (this._micProducer) { @@ -335,7 +382,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'devices.microPhoneMute', + id : 'devices.microphoneMute', defaultMessage : 'Muted your microphone' }) })); @@ -347,7 +394,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'devices.microPhoneUnMute', + id : 'devices.microphoneUnMute', defaultMessage : 'Unmuted your microphone' }) })); @@ -355,7 +402,7 @@ export default class RoomClient } else { - this.enableMic(); + this.updateMic({ start: true }); store.dispatch(requestActions.notify( { @@ -369,12 +416,19 @@ export default class RoomClient break; } - case 'v': // Toggle video + case 'V': // Toggle video { if (this._webcamProducer) this.disableWebcam(); else - this.enableWebcam(); + this.updateWebcam({ start: true }); + + break; + } + + case 'H': // Open help dialog + { + store.dispatch(roomActions.setHelpOpen(true)); break; } @@ -386,6 +440,41 @@ export default class RoomClient } } }); + document.addEventListener('keyup', (event) => + { + const key = String.fromCharCode(event.which); + + const source = event.target; + + const exclude = [ 'input', 'textarea' ]; + + if (exclude.indexOf(source.tagName.toLowerCase()) === -1) + { + logger.debug('keyUp() [key:"%s"]', key); + + switch (key) + { + case ' ': // Push To Talk stop + { + if (this._micProducer) + { + if (!this._micProducer.paused) + { + this.muteMic(); + } + } + + break; + } + default: + { + break; + } + } + } + event.preventDefault(); + }, true); + } _startDevicesListener() @@ -396,6 +485,7 @@ export default class RoomClient await this._updateAudioDevices(); await this._updateWebcams(); + await this._updateAudioOutputDevices(); store.dispatch(requestActions.notify( { @@ -407,16 +497,16 @@ export default class RoomClient }); } - login() + login(roomId = this._roomId) { - const url = `/auth/login?id=${this._peerId}`; + const url = `/auth/login?peerId=${this._peerId}&roomId=${roomId}`; window.open(url, 'loginWindow'); } - logout() + logout(roomId = this._roomId) { - window.open('/auth/logout', 'logoutWindow'); + window.open(`/auth/logout?peerId=${this._peerId}&roomId=${roomId}`, 'logoutWindow'); } receiveLoginChildWindow(data) @@ -425,16 +515,8 @@ export default class RoomClient const { displayName, picture } = data; - if (store.getState().room.state === 'connected') - { - this.changeDisplayName(displayName); - this.changePicture(picture); - } - else - { - store.dispatch(settingsActions.setDisplayName(displayName)); - store.dispatch(meActions.setPicture(picture)); - } + store.dispatch(settingsActions.setDisplayName(displayName)); + store.dispatch(meActions.setPicture(picture)); store.dispatch(meActions.loggedIn(true)); @@ -451,6 +533,8 @@ export default class RoomClient { logger.debug('receiveLogoutChildWindow()'); + store.dispatch(meActions.setPicture(null)); + store.dispatch(meActions.loggedIn(false)); store.dispatch(requestActions.notify( @@ -464,22 +548,22 @@ export default class RoomClient _soundNotification() { - const alertPromise = this._soundAlert.play(); + const { notificationSounds } = store.getState().settings; - if (alertPromise !== undefined) + if (notificationSounds) { - alertPromise - .then() - .catch((error) => - { - logger.error('_soundAlert.play() | failed: %o', error); - }); - } - } + const alertPromise = this._soundAlert.play(); - notify(text) - { - store.dispatch(requestActions.notify({ text: text })); + if (alertPromise !== undefined) + { + alertPromise + .then() + .catch((error) => + { + logger.error('_soundAlert.play() [error:"%o"]', error); + }); + } + } } timeoutCallback(callback) @@ -492,7 +576,7 @@ export default class RoomClient if (called) return; called = true; - callback(new Error('Request timeout.')); + callback(new SocketTimeoutError('Request timed out')); }, ROOM_OPTIONS.requestTimeout ); @@ -508,13 +592,13 @@ export default class RoomClient }; } - sendRequest(method, data) + _sendRequest(method, data) { return new Promise((resolve, reject) => { if (!this._signalingSocket) { - reject('No socket connection.'); + reject('No socket connection'); } else { @@ -524,19 +608,72 @@ export default class RoomClient this.timeoutCallback((err, response) => { if (err) - { reject(err); - } else - { resolve(response); - } }) ); } }); } + async getTransportStats() + { + try + { + if (this._recvTransport) + { + logger.debug('getTransportStats() - recv [transportId: "%s"]', this._recvTransport.id); + + const recv = await this.sendRequest('getTransportStats', { transportId: this._recvTransport.id }); + + store.dispatch( + transportActions.addTransportStats(recv, 'recv')); + } + + if (this._sendTransport) + { + logger.debug('getTransportStats() - send [transportId: "%s"]', this._sendTransport.id); + + const send = await this.sendRequest('getTransportStats', { transportId: this._sendTransport.id }); + + store.dispatch( + transportActions.addTransportStats(send, 'send')); + } + } + catch (error) + { + logger.error('getTransportStats() [error:"%o"]', error); + } + } + + async sendRequest(method, data) + { + logger.debug('sendRequest() [method:"%s", data:"%o"]', method, data); + + const { + requestRetries = 3 + } = window.config; + + for (let tries = 0; tries < requestRetries; tries++) + { + try + { + return await this._sendRequest(method, data); + } + catch (error) + { + if ( + error instanceof SocketTimeoutError && + tries < requestRetries + ) + logger.warn('sendRequest() | timeout, retrying [attempt:"%s"]', tries); + else + throw error; + } + } + } + async changeDisplayName(displayName) { logger.debug('changeDisplayName() [displayName:"%s"]', displayName); @@ -565,7 +702,7 @@ export default class RoomClient } catch (error) { - logger.error('changeDisplayName() | failed: %o', error); + logger.error('changeDisplayName() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -591,7 +728,7 @@ export default class RoomClient } catch (error) { - logger.error('changePicture() | failed: %o', error); + logger.error('changePicture() [error:"%o"]', error); } } @@ -608,7 +745,7 @@ export default class RoomClient } catch (error) { - logger.error('sendChatMessage() | failed: %o', error); + logger.error('sendChatMessage() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -627,7 +764,7 @@ export default class RoomClient { if (err) { - return store.dispatch(requestActions.notify( + store.dispatch(requestActions.notify( { type : 'error', text : intl.formatMessage({ @@ -635,6 +772,8 @@ export default class RoomClient defaultMessage : 'Unable to save file' }) })); + + return; } saveAs(blob, file.name); @@ -651,7 +790,9 @@ export default class RoomClient if (existingTorrent) { // Never add duplicate torrents, use the existing one instead. - return this._handleTorrent(existingTorrent); + this._handleTorrent(existingTorrent); + + return; } this._webTorrent.add(magnetUri, this._handleTorrent); @@ -663,11 +804,13 @@ export default class RoomClient // same file was sent multiple times. if (torrent.progress === 1) { - return store.dispatch( + store.dispatch( fileActions.setFileDone( torrent.magnetURI, torrent.files )); + + return; } let lastMove = 0; @@ -686,7 +829,7 @@ export default class RoomClient } }); - torrent.on('done', () => + torrent.on('done', () => { store.dispatch( fileActions.setFileDone( @@ -710,7 +853,7 @@ export default class RoomClient { if (err) { - return store.dispatch(requestActions.notify( + store.dispatch(requestActions.notify( { type : 'error', text : intl.formatMessage({ @@ -718,18 +861,35 @@ export default class RoomClient defaultMessage : 'Unable to share file' }) })); + + return; } const existingTorrent = this._webTorrent.get(torrent); if (existingTorrent) { - return this._sendFile(existingTorrent.magnetURI); + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'filesharing.successfulFileShare', + defaultMessage : 'File successfully shared' + }) + })); + + store.dispatch(fileActions.addFile( + this._peerId, + existingTorrent.magnetURI + )); + + this._sendFile(existingTorrent.magnetURI); + + return; } this._webTorrent.seed( files, - { announceList: [ [ 'wss://tracker.lab.vvc.niif.hu:443' ] ] }, + { announceList: [ [ this._tracker ] ] }, (newTorrent) => { store.dispatch(requestActions.notify( @@ -753,7 +913,7 @@ export default class RoomClient // { file, name, picture } async _sendFile(magnetUri) { - logger.debug('sendFile() [magnetUri: %o]', magnetUri); + logger.debug('sendFile() [magnetUri:"%o"]', magnetUri); try { @@ -761,7 +921,7 @@ export default class RoomClient } catch (error) { - logger.error('sendFile() | failed: %o', error); + logger.error('sendFile() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -774,62 +934,6 @@ export default class RoomClient } } - async getServerHistory() - { - logger.debug('getServerHistory()'); - - try - { - const { - chatHistory, - fileHistory, - lastNHistory, - locked, - lobbyPeers, - accessCode - } = await this.sendRequest('serverHistory'); - - (chatHistory.length > 0) && store.dispatch( - chatActions.addChatHistory(chatHistory)); - - (fileHistory.length > 0) && store.dispatch( - fileActions.addFileHistory(fileHistory)); - - if (lastNHistory.length > 0) - { - logger.debug('Got lastNHistory'); - - // Remove our self from list - const index = lastNHistory.indexOf(this._peerId); - - lastNHistory.splice(index, 1); - - this._spotlights.addSpeakerList(lastNHistory); - } - - locked ? - store.dispatch(roomActions.setRoomLocked()) : - store.dispatch(roomActions.setRoomUnLocked()); - - (lobbyPeers.length > 0) && lobbyPeers.forEach((peer) => - { - store.dispatch( - lobbyPeerActions.addLobbyPeer(peer.peerId)); - store.dispatch( - lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName)); - store.dispatch( - lobbyPeerActions.setLobbyPeerPicture(peer.picture)); - }); - - (accessCode != null) && store.dispatch( - roomActions.setAccessCode(accessCode)); - } - catch (error) - { - logger.error('getServerHistory() | failed: %o', error); - } - } - async muteMic() { logger.debug('muteMic()'); @@ -846,7 +950,7 @@ export default class RoomClient } catch (error) { - logger.error('muteMic() | failed: %o', error); + logger.error('muteMic() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -865,7 +969,7 @@ export default class RoomClient if (!this._micProducer) { - this.enableMic(); + this.updateMic({ start: true }); } else { @@ -875,13 +979,13 @@ export default class RoomClient { await this.sendRequest( 'resumeProducer', { producerId: this._micProducer.id }); - + store.dispatch( producerActions.setProducerResumed(this._micProducer.id)); } catch (error) { - logger.error('unmuteMic() | failed: %o', error); + logger.error('unmuteMic() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -914,232 +1018,498 @@ export default class RoomClient { if (consumer.kind === 'video') { - if (spotlights.indexOf(consumer.appData.peerId) > -1) - { + if (spotlights.includes(consumer.appData.peerId)) await this._resumeConsumer(consumer); - } else - { await this._pauseConsumer(consumer); - } } } } catch (error) { - logger.error('updateSpotlights() failed: %o', error); + logger.error('updateSpotlights() [error:"%o"]', error); } } - async getAudioTrack() + disconnectLocalHark() { - await navigator.mediaDevices.getUserMedia( - { - audio : true, video : false - }); + logger.debug('disconnectLocalHark()'); + + if (this._harkStream != null) + { + let [ track ] = this._harkStream.getAudioTracks(); + + track.stop(); + track = null; + + this._harkStream = null; + } + + if (this._hark != null) + this._hark.stop(); } - async getVideoTrack() + connectLocalHark(track) { - await navigator.mediaDevices.getUserMedia( + logger.debug('connectLocalHark() [track:"%o"]', track); + + this._harkStream = new MediaStream(); + + const newTrack = track.clone(); + + this._harkStream.addTrack(newTrack); + + newTrack.enabled = true; + + this._hark = hark(this._harkStream, { - audio : false, video : true + play : false, + interval : 10, + threshold : store.getState().settings.noiseThreshold, + history : 100 }); + + this._hark.lastVolume = -100; + + this._hark.on('volume_change', (volume) => + { + volume = Math.round(volume); + + if (this._micProducer && (volume !== Math.round(this._hark.lastVolume))) + { + if (volume < this._hark.lastVolume) + { + volume = + this._hark.lastVolume - + Math.pow( + (volume - this._hark.lastVolume) / + (100 + this._hark.lastVolume) + , 4 + ) * 2; + } + + this._hark.lastVolume = volume; + + store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); + } + }); + + this._hark.on('speaking', () => + { + store.dispatch(meActions.setIsSpeaking(true)); + + if ( + (store.getState().settings.voiceActivatedUnmute || + store.getState().me.isAutoMuted) && + this._micProducer && + this._micProducer.paused + ) + this._micProducer.resume(); + + store.dispatch(meActions.setAutoMuted(false)); // sanity action + }); + + this._hark.on('stopped_speaking', () => + { + store.dispatch(meActions.setIsSpeaking(false)); + + if ( + store.getState().settings.voiceActivatedUnmute && + this._micProducer && + !this._micProducer.paused + ) + { + this._micProducer.pause(); + + store.dispatch(meActions.setAutoMuted(true)); + } + }); } - async changeAudioDevice(deviceId) + async changeAudioOutputDevice(deviceId) { - logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); + logger.debug('changeAudioOutputDevice() [deviceId:"%s"]', deviceId); store.dispatch( - meActions.setAudioInProgress(true)); + meActions.setAudioOutputInProgress(true)); try { + const device = this._audioOutputDevices[deviceId]; + + if (!device) + throw new Error('Selected audio output device no longer available'); + + store.dispatch(settingsActions.setSelectedAudioOutputDevice(deviceId)); + + await this._updateAudioOutputDevices(); + } + catch (error) + { + logger.error('changeAudioOutputDevice() [error:"%o"]', error); + } + + store.dispatch( + meActions.setAudioOutputInProgress(false)); + } + + async updateMic({ start = false, restart = false, newDeviceId = null } = {}) + { + logger.debug( + 'updateMic() [start:"%s", restart:"%s", newDeviceId:"%s"]', + start, + restart, + newDeviceId + ); + + let track; + + try + { + if (!this._mediasoupDevice.canProduce('audio')) + throw new Error('cannot produce audio'); + + if (newDeviceId && !restart) + throw new Error('changing device requires restart'); + + if (newDeviceId) + store.dispatch(settingsActions.setSelectedAudioDevice(newDeviceId)); + + store.dispatch(meActions.setAudioInProgress(true)); + + const deviceId = await this._getAudioDeviceId(); const device = this._audioDevices[deviceId]; if (!device) throw new Error('no audio devices'); - logger.debug( - 'changeAudioDevice() | new selected webcam [device:%o]', - device); + const { + sampleRate, + channelCount, + volume, + autoGainControl, + echoCancellation, + noiseSuppression, + sampleSize + } = store.getState().settings; - if (this._micProducer && this._micProducer.track) - this._micProducer.track.stop(); + if ( + (restart && this._micProducer) || + start + ) + { + this.disconnectLocalHark(); - logger.debug('changeAudioDevice() | calling getUserMedia()'); + if (this._micProducer) + await this.disableMic(); - const stream = await navigator.mediaDevices.getUserMedia( - { - audio : + const stream = await navigator.mediaDevices.getUserMedia( { - deviceId : { exact: device.deviceId } + audio : { + deviceId : { ideal: deviceId }, + sampleRate, + channelCount, + volume, + autoGainControl, + echoCancellation, + noiseSuppression, + sampleSize + } } + ); + + ([ track ] = stream.getAudioTracks()); + + const { deviceId: trackDeviceId } = track.getSettings(); + + store.dispatch(settingsActions.setSelectedAudioDevice(trackDeviceId)); + + this._micProducer = await this._sendTransport.produce( + { + track, + codecOptions : + { + opusStereo : false, + opusDtx : true, + opusFec : true, + opusPtime : '3', + opusMaxPlaybackRate : 48000 + }, + appData : + { source: 'mic' } + }); + + store.dispatch(producerActions.addProducer( + { + 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] + })); + + this._micProducer.on('transportclose', () => + { + this._micProducer = null; }); - const track = stream.getAudioTracks()[0]; + this._micProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.microphoneDisconnected', + defaultMessage : 'Microphone disconnected' + }) + })); - if (this._micProducer) - await this._micProducer.replaceTrack({ track }); + this.disableMic(); + }); - if (this._micProducer) this._micProducer.volume = 0; - const harkStream = new MediaStream(); - - harkStream.addTrack(track); - - if (!harkStream.getAudioTracks()[0]) - throw new Error('changeAudioDevice(): given stream has no audio track'); - - if (this._hark != null) - this._hark.stop(); - - this._hark = hark(harkStream, { play: false }); - - // eslint-disable-next-line no-unused-vars - this._hark.on('volume_change', (dBs, threshold) => - { - // The exact formula to convert from dBs (-100..0) to linear (0..1) is: - // Math.pow(10, dBs / 20) - // However it does not produce a visually useful output, so let exaggerate - // 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 (this._micProducer && volume !== this._micProducer.volume) - { - this._micProducer.volume = volume; - - store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); - } - }); - if (this._micProducer && this._micProducer.id) - store.dispatch( - producerActions.setProducerTrack(this._micProducer.id, track)); - - store.dispatch(settingsActions.setSelectedAudioDevice(deviceId)); - - await this._updateAudioDevices(); - } - catch (error) - { - logger.error('changeAudioDevice() failed: %o', error); - } - - store.dispatch( - meActions.setAudioInProgress(false)); - } - - async changeVideoResolution(resolution) - { - logger.debug('changeVideoResolution() [resolution: %s]', resolution); - - store.dispatch( - meActions.setWebcamInProgress(true)); - - try - { - const deviceId = await this._getWebcamDeviceId(); - - const device = this._webcams[deviceId]; - - if (!device) - throw new Error('no webcam devices'); - - this._webcamProducer.track.stop(); - - logger.debug('changeVideoResolution() | calling getUserMedia()'); - - const stream = await navigator.mediaDevices.getUserMedia( - { - video : - { - deviceId : { exact: device.deviceId }, - ...VIDEO_CONSTRAINS[resolution] - } - }); - - const track = stream.getVideoTracks()[0]; - - await this._webcamProducer.replaceTrack({ track }); - - store.dispatch( - producerActions.setProducerTrack(this._webcamProducer.id, track)); - - store.dispatch(settingsActions.setSelectedWebcamDevice(deviceId)); - store.dispatch(settingsActions.setVideoResolution(resolution)); - - await this._updateWebcams(); - } - catch (error) - { - logger.error('changeVideoResolution() failed: %o', error); - } - - store.dispatch( - meActions.setWebcamInProgress(false)); - } - - async changeWebcam(deviceId) - { - logger.debug('changeWebcam() [deviceId: %s]', deviceId); - - store.dispatch( - meActions.setWebcamInProgress(true)); - - try - { - const device = this._webcams[deviceId]; - const resolution = store.getState().settings.resolution; - - if (!device) - throw new Error('no webcam devices'); - - logger.debug( - 'changeWebcam() | new selected webcam [device:%o]', - device); - if (this._webcamProducer && this._webcamProducer.track) - this._webcamProducer.track.stop(); - - logger.debug('changeWebcam() | calling getUserMedia()'); - - const stream = await navigator.mediaDevices.getUserMedia( - { - video : - { - deviceId : { exact: device.deviceId }, - ...VIDEO_CONSTRAINS[resolution] - } - }); - if (stream){ - const track = stream.getVideoTracks()[0]; - if (track) { - await this._webcamProducer.replaceTrack({ track }); - - store.dispatch( - producerActions.setProducerTrack(this._webcamProducer.id, track)); - - } else { - logger.warn('getVideoTracks Error: First Video Track is null') - } - - } else { - logger.warn ('getUserMedia Error: Stream is null!') + this.connectLocalHark(track); } - store.dispatch(settingsActions.setSelectedWebcamDevice(deviceId)); + else if (this._micProducer) + { + ({ track } = this._micProducer); - await this._updateWebcams(); + await track.applyConstraints( + { + sampleRate, + channelCount, + volume, + autoGainControl, + echoCancellation, + noiseSuppression, + sampleSize + } + ); + + if (this._harkStream != null) + { + const [ harkTrack ] = this._harkStream.getAudioTracks(); + + harkTrack && await harkTrack.applyConstraints( + { + sampleRate, + channelCount, + volume, + autoGainControl, + echoCancellation, + noiseSuppression, + sampleSize + } + ); + } + } } catch (error) { - logger.error('changeWebcam() failed: %o', error); + logger.error('updateMic() [error:"%o"]', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.microphoneError', + defaultMessage : 'An error occurred while accessing your microphone' + }) + })); + + if (track) + track.stop(); + } + + store.dispatch(meActions.setAudioInProgress(false)); + } + + async updateWebcam({ + start = false, + restart = false, + newDeviceId = null, + newResolution = null, + newFrameRate = null + } = {}) + { + logger.debug( + 'updateWebcam() [start:"%s", restart:"%s", newDeviceId:"%s", newResolution:"%s", newFrameRate:"%s"]', + start, + restart, + newDeviceId, + newResolution, + newFrameRate + ); + + let track; + + try + { + if (!this._mediasoupDevice.canProduce('video')) + throw new Error('cannot produce video'); + + if (newDeviceId && !restart) + throw new Error('changing device requires restart'); + + if (newDeviceId) + store.dispatch(settingsActions.setSelectedWebcamDevice(newDeviceId)); + + if (newResolution) + store.dispatch(settingsActions.setVideoResolution(newResolution)); + + if (newFrameRate) + store.dispatch(settingsActions.setVideoFrameRate(newFrameRate)); + + store.dispatch(meActions.setWebcamInProgress(true)); + + const deviceId = await this._getWebcamDeviceId(); + const device = this._webcams[deviceId]; + + if (!device) + throw new Error('no webcam devices'); + + const { + resolution, + frameRate + } = store.getState().settings; + + if ( + (restart && this._webcamProducer) || + start + ) + { + if (this._webcamProducer) + await this.disableWebcam(); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { ideal: deviceId }, + ...VIDEO_CONSTRAINS[resolution], + frameRate + } + }); + + ([ track ] = stream.getVideoTracks()); + + const { deviceId: trackDeviceId } = track.getSettings(); + + store.dispatch(settingsActions.setSelectedWebcamDevice(trackDeviceId)); + + 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, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'webcam' + } + }); + } + else + { + this._webcamProducer = await this._sendTransport.produce({ + track, + appData : + { + source : 'webcam' + } + }); + } + + store.dispatch(producerActions.addProducer( + { + id : this._webcamProducer.id, + source : 'webcam', + paused : this._webcamProducer.paused, + track : this._webcamProducer.track, + rtpParameters : this._webcamProducer.rtpParameters, + codec : this._webcamProducer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + this._webcamProducer.on('transportclose', () => + { + this._webcamProducer = null; + }); + + this._webcamProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.cameraDisconnected', + defaultMessage : 'Camera disconnected' + }) + })); + + this.disableWebcam(); + }); + } + else if (this._webcamProducer) + { + ({ track } = this._webcamProducer); + + await track.applyConstraints( + { + ...VIDEO_CONSTRAINS[resolution], + frameRate + } + ); + + // Also change resolution of extra video producers + for (const producer of this._extraVideoProducers.values()) + { + ({ track } = producer); + + await track.applyConstraints( + { + ...VIDEO_CONSTRAINS[resolution], + frameRate + } + ); + } + } + } + catch (error) + { + logger.error('updateWebcam() [error:"%o"]', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.cameraError', + defaultMessage : 'An error occurred while accessing your camera' + }) + })); + + if (track) + track.stop(); } store.dispatch( @@ -1156,6 +1526,26 @@ export default class RoomClient roomActions.setSelectedPeer(peerId)); } + async promoteAllLobbyPeers() + { + logger.debug('promoteAllLobbyPeers()'); + + store.dispatch( + roomActions.setLobbyPeersPromotionInProgress(true)); + + try + { + await this.sendRequest('promoteAllPeers'); + } + catch (error) + { + logger.error('promoteAllLobbyPeers() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setLobbyPeersPromotionInProgress(false)); + } + async promoteLobbyPeer(peerId) { logger.debug('promoteLobbyPeer() [peerId:"%s"]', peerId); @@ -1169,13 +1559,217 @@ export default class RoomClient } catch (error) { - logger.error('promoteLobbyPeer() failed: %o', error); + logger.error('promoteLobbyPeer() [error:"%o"]', error); } store.dispatch( lobbyPeerActions.setLobbyPeerPromotionInProgress(peerId, false)); } + async clearChat() + { + logger.debug('clearChat()'); + + store.dispatch( + roomActions.setClearChatInProgress(true)); + + try + { + await this.sendRequest('moderator:clearChat'); + + store.dispatch(chatActions.clearChat()); + } + catch (error) + { + logger.error('clearChat() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setClearChatInProgress(false)); + } + + async clearFileSharing() + { + logger.debug('clearFileSharing()'); + + store.dispatch( + roomActions.setClearFileSharingInProgress(true)); + + try + { + await this.sendRequest('moderator:clearFileSharing'); + + store.dispatch(fileActions.clearFiles()); + } + catch (error) + { + logger.error('clearFileSharing() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setClearFileSharingInProgress(false)); + } + + async kickPeer(peerId) + { + logger.debug('kickPeer() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setPeerKickInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:kickPeer', { peerId }); + } + catch (error) + { + logger.error('kickPeer() [error:"%o"]', error); + } + + store.dispatch( + peerActions.setPeerKickInProgress(peerId, false)); + } + + async mutePeer(peerId) + { + logger.debug('mutePeer() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setMutePeerInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:mute', { peerId }); + } + catch (error) + { + logger.error('mutePeer() [error:"%o"]', error); + } + + store.dispatch( + peerActions.setMutePeerInProgress(peerId, false)); + } + + async stopPeerVideo(peerId) + { + logger.debug('stopPeerVideo() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setStopPeerVideoInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:stopVideo', { peerId }); + } + catch (error) + { + logger.error('stopPeerVideo() [error:"%o"]', error); + } + + store.dispatch( + peerActions.setStopPeerVideoInProgress(peerId, false)); + } + + async stopPeerScreenSharing(peerId) + { + logger.debug('stopPeerScreenSharing() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setStopPeerScreenSharingInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:stopScreenSharing', { peerId }); + } + catch (error) + { + logger.error('stopPeerScreenSharing() [error:"%o"]', error); + } + + store.dispatch( + peerActions.setStopPeerScreenSharingInProgress(peerId, false)); + } + + async muteAllPeers() + { + logger.debug('muteAllPeers()'); + + store.dispatch( + roomActions.setMuteAllInProgress(true)); + + try + { + await this.sendRequest('moderator:muteAll'); + } + catch (error) + { + logger.error('muteAllPeers() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setMuteAllInProgress(false)); + } + + async stopAllPeerVideo() + { + logger.debug('stopAllPeerVideo()'); + + store.dispatch( + roomActions.setStopAllVideoInProgress(true)); + + try + { + await this.sendRequest('moderator:stopAllVideo'); + } + catch (error) + { + logger.error('stopAllPeerVideo() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setStopAllVideoInProgress(false)); + } + + async stopAllPeerScreenSharing() + { + logger.debug('stopAllPeerScreenSharing()'); + + store.dispatch( + roomActions.setStopAllScreenSharingInProgress(true)); + + try + { + await this.sendRequest('moderator:stopAllScreenSharing'); + } + catch (error) + { + logger.error('stopAllPeerScreenSharing() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setStopAllScreenSharingInProgress(false)); + } + + async closeMeeting() + { + logger.debug('closeMeeting()'); + + store.dispatch( + roomActions.setCloseMeetingInProgress(true)); + + try + { + await this.sendRequest('moderator:closeMeeting'); + } + catch (error) + { + logger.error('closeMeeting() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setCloseMeetingInProgress(false)); + } + // type: mic/webcam/screen // mute: true/false async modifyPeerConsumer(peerId, type, mute) @@ -1203,9 +1797,7 @@ export default class RoomClient if (consumer.appData.peerId === peerId && consumer.appData.source === type) { if (mute) - { await this._pauseConsumer(consumer); - } else await this._resumeConsumer(consumer); } @@ -1213,7 +1805,7 @@ export default class RoomClient } catch (error) { - logger.error('modifyPeerConsumer() failed: %o', error); + logger.error('modifyPeerConsumer() [error:"%o"]', error); } if (type === 'mic') @@ -1229,7 +1821,7 @@ export default class RoomClient async _pauseConsumer(consumer) { - logger.debug('_pauseConsumer() [consumer: %o]', consumer); + logger.debug('_pauseConsumer() [consumer:"%o"]', consumer); if (consumer.paused || consumer.closed) return; @@ -1245,13 +1837,13 @@ export default class RoomClient } catch (error) { - logger.error('_pauseConsumer() | failed:%o', error); + logger.error('_pauseConsumer() [error:"%o"]', error); } } async _resumeConsumer(consumer) { - logger.debug('_resumeConsumer() [consumer: %o]', consumer); + logger.debug('_resumeConsumer() [consumer:"%o"]', consumer); if (!consumer.paused || consumer.closed) return; @@ -1267,39 +1859,59 @@ export default class RoomClient } catch (error) { - logger.error('_resumeConsumer() | failed:%o', error); + logger.error('_resumeConsumer() [error:"%o"]', error); } } - async sendRaiseHandState(state) + async lowerPeerHand(peerId) { - logger.debug('sendRaiseHandState: ', state); + logger.debug('lowerPeerHand() [peerId:"%s"]', peerId); store.dispatch( - meActions.setMyRaiseHandStateInProgress(true)); + peerActions.setPeerRaisedHandInProgress(peerId, true)); try { - await this.sendRequest('raiseHand', { raiseHandState: state }); - - store.dispatch( - meActions.setMyRaiseHandState(state)); + await this.sendRequest('moderator:lowerHand', { peerId }); } catch (error) { - logger.error('sendRaiseHandState() | failed: %o', error); - - // We need to refresh the component for it to render changed state - store.dispatch(meActions.setMyRaiseHandState(!state)); + logger.error('lowerPeerHand() [error:"%o"]', error); } store.dispatch( - meActions.setMyRaiseHandStateInProgress(false)); + peerActions.setPeerRaisedHandInProgress(peerId, false)); + } + + async setRaisedHand(raisedHand) + { + logger.debug('setRaisedHand: ', raisedHand); + + store.dispatch( + meActions.setRaisedHandInProgress(true)); + + try + { + await this.sendRequest('raisedHand', { raisedHand }); + + store.dispatch( + meActions.setRaisedHand(raisedHand)); + } + catch (error) + { + logger.error('setRaisedHand() [error:"%o"]', error); + + // We need to refresh the component for it to render changed state + store.dispatch(meActions.setRaisedHand(!raisedHand)); + } + + store.dispatch( + meActions.setRaisedHandInProgress(false)); } async setMaxSendingSpatialLayer(spatialLayer) { - logger.debug('setMaxSendingSpatialLayer() [spatialLayer:%s]', spatialLayer); + logger.debug('setMaxSendingSpatialLayer() [spatialLayer:"%s"]', spatialLayer); try { @@ -1310,14 +1922,14 @@ export default class RoomClient } catch (error) { - logger.error('setMaxSendingSpatialLayer() | failed:"%o"', error); + logger.error('setMaxSendingSpatialLayer() [error:"%o"]', error); } } async setConsumerPreferredLayers(consumerId, spatialLayer, temporalLayer) { logger.debug( - 'setConsumerPreferredLayers() [consumerId:%s, spatialLayer:%s, temporalLayer:%s]', + 'setConsumerPreferredLayers() [consumerId:"%s", spatialLayer:"%s", temporalLayer:"%s"]', consumerId, spatialLayer, temporalLayer); try @@ -1330,14 +1942,14 @@ export default class RoomClient } catch (error) { - logger.error('setConsumerPreferredLayers() | failed:"%o"', error); + logger.error('setConsumerPreferredLayers() [error:"%o"]', error); } } async setConsumerPriority(consumerId, priority) { logger.debug( - 'setConsumerPriority() [consumerId:%s, priority:%d]', + 'setConsumerPriority() [consumerId:"%s", priority:%d]', consumerId, priority); try @@ -1348,13 +1960,13 @@ export default class RoomClient } catch (error) { - logger.error('setConsumerPriority() | failed:%o', error); + logger.error('setConsumerPriority() [error:"%o"]', error); } } async requestConsumerKeyFrame(consumerId) { - logger.debug('requestConsumerKeyFrame() [consumerId:%s]', consumerId); + logger.debug('requestConsumerKeyFrame() [consumerId:"%s"]', consumerId); try { @@ -1362,7 +1974,7 @@ export default class RoomClient } catch (error) { - logger.error('requestConsumerKeyFrame() | failed:%o', error); + logger.error('requestConsumerKeyFrame() [error:"%o"]', error); } } @@ -1428,27 +2040,6 @@ export default class RoomClient this._signalingUrl = getSignalingUrl(this._peerId, roomId); - this._torrentSupport = WebTorrent.WEBRTC_SUPPORT; - - this._webTorrent = this._torrentSupport && new WebTorrent({ - tracker : { - rtcConfig : { - iceServers : ROOM_OPTIONS.turnServers - } - } - }); - - this._webTorrent.on('error', (error) => - { - logger.error('Filesharing [error:"%o"]', error); - - store.dispatch(requestActions.notify( - { - type : 'error', - text : intl.formatMessage({ id: 'filesharing.error', defaultMessage: 'There was a filesharing error' }) - })); - }); - this._screenSharing = ScreenShare.create(this._device); this._signalingSocket = io(this._signalingUrl); @@ -1490,6 +2081,55 @@ export default class RoomClient }) })); + if (this._screenSharingProducer) + { + this._screenSharingProducer.close(); + + store.dispatch( + producerActions.removeProducer(this._screenSharingProducer.id)); + + this._screenSharingProducer = null; + } + + if (this._webcamProducer) + { + this._webcamProducer.close(); + + store.dispatch( + producerActions.removeProducer(this._webcamProducer.id)); + + this._webcamProducer = null; + } + + if (this._micProducer) + { + this._micProducer.close(); + + store.dispatch( + producerActions.removeProducer(this._micProducer.id)); + + this._micProducer = null; + } + + if (this._sendTransport) + { + this._sendTransport.close(); + + this._sendTransport = null; + } + + if (this._recvTransport) + { + this._recvTransport.close(); + + this._recvTransport = null; + } + + this._spotlights.clearSpotlights(); + + store.dispatch(peerActions.clearPeers()); + store.dispatch(consumerActions.clearConsumers()); + store.dispatch(roomActions.clearSpotlights()); store.dispatch(roomActions.setRoomState('connecting')); }); @@ -1526,7 +2166,7 @@ export default class RoomClient this._signalingSocket.on('request', async (request, cb) => { logger.debug( - 'socket "request" event [method:%s, data:%o]', + 'socket "request" event [method:"%s", data:"%o"]', request.method, request.data); switch (request.method) @@ -1544,23 +2184,12 @@ export default class RoomClient 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. }); @@ -1613,19 +2242,8 @@ export default class RoomClient consumer.hark = hark(stream, { play: false }); - // eslint-disable-next-line no-unused-vars - consumer.hark.on('volume_change', (dBs, threshold) => + consumer.hark.on('volume_change', (volume) => { - // 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 exaggerate - // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to - // minimize component renderings. - let volume = Math.round(Math.pow(10, dBs / 85) * 10); - - if (volume === 1) - volume = 0; - volume = Math.round(volume); if (consumer && volume !== consumer.volume) @@ -1652,20 +2270,21 @@ export default class RoomClient this._signalingSocket.on('notification', async (notification) => { logger.debug( - 'socket "notification" event [method:%s, data:%o]', + 'socket "notification" event [method:"%s", data:"%o"]', notification.method, notification.data); try { switch (notification.method) { + case 'enteredLobby': { store.dispatch(roomActions.setInLobby(true)); const { displayName } = store.getState().settings; const { picture } = store.getState().me; - + await this.sendRequest('changeDisplayName', { displayName }); await this.sendRequest('changePicture', { picture }); break; @@ -1674,25 +2293,43 @@ export default class RoomClient case 'signInRequired': { store.dispatch(roomActions.setSignInRequired(true)); - + break; } - + + case 'overRoomLimit': + { + store.dispatch(roomActions.setOverRoomLimit(true)); + + break; + } + case 'roomReady': { + const { turnServers } = notification.data; + + this._turnServers = turnServers; + store.dispatch(roomActions.toggleJoined()); store.dispatch(roomActions.setInLobby(false)); - + await this._joinRoom({ joinVideo }); - + break; } - + + case 'roomBack': + { + await this._joinRoom({ joinVideo }); + + break; + } + case 'lockRoom': { store.dispatch( roomActions.setRoomLocked()); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1700,15 +2337,15 @@ export default class RoomClient defaultMessage : 'Room is now locked' }) })); - + break; } - + case 'unlockRoom': { store.dispatch( roomActions.setRoomUnLocked()); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1716,19 +2353,21 @@ export default class RoomClient defaultMessage : 'Room is now unlocked' }) })); - + break; } - + case 'parkedPeer': { const { peerId } = notification.data; - + store.dispatch( lobbyPeerActions.addLobbyPeer(peerId)); store.dispatch( roomActions.setToolbarsVisible(true)); - + + this._soundNotification(); + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1736,17 +2375,60 @@ export default class RoomClient defaultMessage : 'New participant entered the lobby' }) })); - + break; } - + + case 'parkedPeers': + { + const { lobbyPeers } = notification.data; + + if (lobbyPeers.length > 0) + { + lobbyPeers.forEach((peer) => + { + store.dispatch( + lobbyPeerActions.addLobbyPeer(peer.id)); + + store.dispatch( + lobbyPeerActions.setLobbyPeerDisplayName( + peer.displayName, + peer.id + ) + ); + + store.dispatch( + lobbyPeerActions.setLobbyPeerPicture( + peer.picture, + peer.id + ) + ); + }); + + store.dispatch( + roomActions.setToolbarsVisible(true)); + + this._soundNotification(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'room.newLobbyPeer', + defaultMessage : 'New participant entered the lobby' + }) + })); + } + + break; + } + case 'lobby:peerClosed': { const { peerId } = notification.data; - + store.dispatch( lobbyPeerActions.removeLobbyPeer(peerId)); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -1754,10 +2436,10 @@ export default class RoomClient defaultMessage : 'Participant in lobby left' }) })); - + break; } - + case 'lobby:promotedPeer': { const { peerId } = notification.data; @@ -1767,7 +2449,7 @@ export default class RoomClient break; } - + case 'lobby:changeDisplayName': { const { peerId, displayName } = notification.data; @@ -1787,11 +2469,11 @@ export default class RoomClient break; } - + case 'lobby:changePicture': { const { peerId, picture } = notification.data; - + store.dispatch( lobbyPeerActions.setLobbyPeerPicture(picture, peerId)); @@ -1809,7 +2491,7 @@ export default class RoomClient case 'setAccessCode': { const { accessCode } = notification.data; - + store.dispatch( roomActions.setAccessCode(accessCode)); @@ -1823,14 +2505,14 @@ export default class RoomClient break; } - + case 'setJoinByAccessCode': { const { joinByAccessCode } = notification.data; - + store.dispatch( roomActions.setJoinByAccessCode(joinByAccessCode)); - + if (joinByAccessCode) { store.dispatch(requestActions.notify( @@ -1841,7 +2523,7 @@ export default class RoomClient }) })); } - else + else { store.dispatch(requestActions.notify( { @@ -1854,20 +2536,20 @@ export default class RoomClient break; } - + case 'activeSpeaker': { const { peerId } = notification.data; - + store.dispatch( roomActions.setRoomActiveSpeaker(peerId)); if (peerId && peerId !== this._peerId) this._spotlights.handleActiveSpeaker(peerId); - + break; } - + case 'changeDisplayName': { const { peerId, displayName, oldDisplayName } = notification.data; @@ -1898,6 +2580,58 @@ export default class RoomClient break; } + case 'raisedHand': + { + const { + peerId, + raisedHand, + raisedHandTimestamp + } = notification.data; + + store.dispatch( + peerActions.setPeerRaisedHand( + peerId, + raisedHand, + raisedHandTimestamp + ) + ); + + const { displayName } = store.getState().peers[peerId]; + + let text; + + if (raisedHand) + { + text = intl.formatMessage({ + id : 'room.raisedHand', + defaultMessage : '{displayName} raised their hand' + }, { + displayName + }); + } + else + { + text = intl.formatMessage({ + id : 'room.loweredHand', + defaultMessage : '{displayName} put their hand down' + }, { + displayName + }); + } + + if (displayName) + { + store.dispatch(requestActions.notify( + { + text + })); + } + + this._soundNotification(); + + break; + } + case 'chatMessage': { const { peerId, chatMessage } = notification.data; @@ -1919,6 +2653,21 @@ export default class RoomClient break; } + case 'moderator:clearChat': + { + store.dispatch(chatActions.clearChat()); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.clearChat', + defaultMessage : 'Moderator cleared the chat' + }) + })); + + break; + } + case 'sendFile': { const { peerId, magnetUri } = notification.data; @@ -1947,6 +2696,21 @@ export default class RoomClient break; } + case 'moderator:clearFileSharing': + { + store.dispatch(fileActions.clearFiles()); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.clearFiles', + defaultMessage : 'Moderator cleared the files' + }) + })); + + break; + } + case 'producerScore': { const { producerId, score } = notification.data; @@ -1959,10 +2723,12 @@ export default class RoomClient case 'newPeer': { - const { id, displayName, picture } = notification.data; + const { id, displayName, picture, roles } = notification.data; store.dispatch( - peerActions.addPeer({ id, displayName, picture, consumers: [] })); + peerActions.addPeer({ id, displayName, picture, roles, consumers: [] })); + + this._soundNotification(); store.dispatch(requestActions.notify( { @@ -1991,77 +2757,188 @@ export default class RoomClient { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + consumer.close(); - + if (consumer.hark != null) consumer.hark.stop(); - + this._consumers.delete(consumerId); - + const { peerId } = consumer.appData; - + store.dispatch( consumerActions.removeConsumer(consumerId, peerId)); - + break; } - + case 'consumerPaused': { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch( consumerActions.setConsumerPaused(consumerId, 'remote')); break; } - + case 'consumerResumed': { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch( consumerActions.setConsumerResumed(consumerId, 'remote')); - + break; } - + case 'consumerLayersChanged': { const { consumerId, spatialLayer, temporalLayer } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch(consumerActions.setConsumerCurrentLayers( consumerId, spatialLayer, temporalLayer)); - + break; } - + case 'consumerScore': { const { consumerId, score } = notification.data; - + store.dispatch( consumerActions.setConsumerScore(consumerId, score)); - + break; } - + + case 'moderator:mute': + { + if (this._micProducer && !this._micProducer.paused) + { + this.muteMic(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.muteAudio', + defaultMessage : 'Moderator muted your audio' + }) + })); + } + + break; + } + + case 'moderator:stopVideo': + { + this.disableWebcam(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.muteVideo', + defaultMessage : 'Moderator stopped your video' + }) + })); + + break; + } + + case 'moderator:stopScreenSharing': + { + this.disableScreenSharing(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.stopScreenSharing', + defaultMessage : 'Moderator stopped your screen sharing' + }) + })); + + break; + } + + case 'moderator:kick': + { + // Need some feedback + this.close(); + + break; + } + + case 'moderator:lowerHand': + { + this.setRaisedHand(false); + + break; + } + + case 'gotRole': + { + const { peerId, role } = notification.data; + + if (peerId === this._peerId) + { + store.dispatch(meActions.addRole(role)); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.gotRole', + defaultMessage : 'You got the role: {role}' + }, { + role + }) + })); + } + else + store.dispatch(peerActions.addPeerRole(peerId, role)); + + break; + } + + case 'lostRole': + { + const { peerId, role } = notification.data; + + if (peerId === this._peerId) + { + store.dispatch(meActions.removeRole(role)); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.lostRole', + defaultMessage : 'You lost the role: {role}' + }, { + role + }) + })); + } + else + store.dispatch(peerActions.removePeerRole(peerId, role)); + + break; + } + default: { logger.error( @@ -2071,7 +2948,7 @@ export default class RoomClient } catch (error) { - logger.error('error on socket "notification" event failed:"%o"', error); + logger.error('error on socket "notification" event [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -2090,10 +2967,8 @@ export default class RoomClient { logger.debug('_joinRoom()'); - const { - displayName, - picture - } = store.getState().settings; + const { displayName } = store.getState().settings; + const { picture } = store.getState().me; try { @@ -2110,7 +2985,7 @@ export default class RoomClient this._webTorrent.on('error', (error) => { logger.error('Filesharing [error:"%o"]', error); - + store.dispatch(requestActions.notify( { type : 'error', @@ -2126,6 +3001,9 @@ export default class RoomClient const routerRtpCapabilities = await this.sendRequest('getRouterRtpCapabilities'); + routerRtpCapabilities.headerExtensions = routerRtpCapabilities.headerExtensions + .filter((ext) => ext.uri !== 'urn:3gpp:video-orientation'); + await this._mediasoupDevice.load({ routerRtpCapabilities }); if (this._produce) @@ -2151,7 +3029,9 @@ export default class RoomClient iceParameters, iceCandidates, dtlsParameters, - iceServers : ROOM_OPTIONS.turnServers, + iceServers : this._turnServers, + // TODO: Fix for issue #72 + iceTransportPolicy : this._device.flag === 'firefox' && this._turnServers ? 'relay' : undefined, proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS }); @@ -2213,7 +3093,9 @@ export default class RoomClient iceParameters, iceCandidates, dtlsParameters, - iceServers : ROOM_OPTIONS.turnServers + iceServers : this._turnServers, + // TODO: Fix for issue #72 + iceTransportPolicy : this._device.flag === 'firefox' && this._turnServers ? 'relay' : undefined }); this._recvTransport.on( @@ -2239,7 +3121,20 @@ export default class RoomClient canShareFiles : this._torrentSupport })); - const { peers, authenticated } = await this.sendRequest( + const { + authenticated, + roles, + peers, + tracker, + roomPermissions, + allowWhenRoleMissing, + chatHistory, + fileHistory, + lastNHistory, + locked, + lobbyPeers, + accessCode + } = await this.sendRequest( 'join', { displayName : displayName, @@ -2247,9 +3142,41 @@ export default class RoomClient rtpCapabilities : this._mediasoupDevice.rtpCapabilities }); + logger.debug( + '_joinRoom() joined [authenticated:"%s", peers:"%o", roles:"%o"]', + authenticated, + peers, + roles + ); + + tracker && (this._tracker = tracker); + store.dispatch(meActions.loggedIn(authenticated)); - logger.debug('_joinRoom() joined, got peers [peers:"%o"]', peers); + store.dispatch(roomActions.setRoomPermissions(roomPermissions)); + + if (allowWhenRoleMissing) + store.dispatch(roomActions.setAllowWhenRoleMissing(allowWhenRoleMissing)); + + const myRoles = store.getState().me.roles; + + for (const role of roles) + { + if (!myRoles.includes(role)) + { + store.dispatch(meActions.addRole(role)); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.gotRole', + defaultMessage : 'You got the role: {role}' + }, { + role + }) + })); + } + } for (const peer of peers) { @@ -2265,15 +3192,70 @@ export default class RoomClient this.updateSpotlights(spotlights); }); + (chatHistory.length > 0) && store.dispatch( + chatActions.addChatHistory(chatHistory)); + + (fileHistory.length > 0) && store.dispatch( + fileActions.addFileHistory(fileHistory)); + + if (lastNHistory.length > 0) + { + logger.debug('_joinRoom() | got lastN history'); + + this._spotlights.addSpeakerList( + lastNHistory.filter((peerId) => peerId !== this._peerId) + ); + } + + locked ? + store.dispatch(roomActions.setRoomLocked()) : + store.dispatch(roomActions.setRoomUnLocked()); + + (lobbyPeers.length > 0) && lobbyPeers.forEach((peer) => + { + store.dispatch( + lobbyPeerActions.addLobbyPeer(peer.id)); + store.dispatch( + lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName, peer.id)); + store.dispatch( + lobbyPeerActions.setLobbyPeerPicture(peer.picture, peer.id)); + }); + + (accessCode != null) && store.dispatch( + roomActions.setAccessCode(accessCode)); + // Don't produce if explicitly requested to not to do it. if (this._produce) { if (this._mediasoupDevice.canProduce('audio')) if (!this._muted) - this.enableMic(); + { + await this.updateMic({ start: true }); + let autoMuteThreshold = 4; - if (joinVideo && this._mediasoupDevice.canProduce('video')) - this.enableWebcam(); + if ('autoMuteThreshold' in window.config) + { + autoMuteThreshold = window.config.autoMuteThreshold; + } + if (autoMuteThreshold && peers.length >= autoMuteThreshold) + this.muteMic(); + } + + if (joinVideo) + this.updateWebcam({ start: true }); + } + + await this._updateAudioOutputDevices(); + + const { selectedAudioOutputDevice } = store.getState().settings; + + if (!selectedAudioOutputDevice && this._audioOutputDevices !== {}) + { + store.dispatch( + settingsActions.setSelectedAudioOutputDevice( + Object.keys(this._audioOutputDevices)[0] + ) + ); } store.dispatch(roomActions.setRoomState('connected')); @@ -2281,8 +3263,6 @@ export default class RoomClient // Clean all the existing notifications. store.dispatch(notificationActions.removeAllNotifications()); - this.getServerHistory(); - store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2295,7 +3275,7 @@ export default class RoomClient } catch (error) { - logger.error('_joinRoom() failed:"%o"', error); + logger.error('_joinRoom() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -2340,7 +3320,7 @@ export default class RoomClient }) })); - logger.error('lockRoom() | failed: %o', error); + logger.error('lockRoom() [error:"%o"]', error); } } @@ -2374,7 +3354,7 @@ export default class RoomClient }) })); - logger.error('unlockRoom() | failed: %o', error); + logger.error('unlockRoom() [error:"%o"]', error); } } @@ -2396,7 +3376,7 @@ export default class RoomClient } catch (error) { - logger.error('setAccessCode() | failed: %o', error); + logger.error('setAccessCode() [error:"%o"]', error); store.dispatch(requestActions.notify( { type : 'error', @@ -2423,7 +3403,7 @@ export default class RoomClient } catch (error) { - logger.error('setAccessCode() | failed: %o', error); + logger.error('setAccessCode() [error:"%o"]', error); store.dispatch(requestActions.notify( { type : 'error', @@ -2432,14 +3412,19 @@ export default class RoomClient } } - async enableMic() + async addExtraVideo(videoDeviceId) { - if (this._micProducer) - return; + logger.debug( + 'addExtraVideo() [videoDeviceId:"%s"]', + videoDeviceId + ); - if (this._mediasoupDevice && !this._mediasoupDevice.canProduce('audio')) + store.dispatch( + roomActions.setExtraVideoOpen(false)); + + if (!this._mediasoupDevice.canProduce('video')) { - logger.error('enableMic() | cannot produce audio'); + logger.error('addExtraVideo() | cannot produce video'); return; } @@ -2447,136 +3432,131 @@ export default class RoomClient let track; store.dispatch( - meActions.setAudioInProgress(true)); + meActions.setWebcamInProgress(true)); try { - const deviceId = await this._getAudioDeviceId(); - - const device = this._audioDevices[deviceId]; + const device = this._webcams[videoDeviceId]; + const resolution = store.getState().settings.resolution; if (!device) - throw new Error('no audio devices'); - - logger.debug( - 'enableMic() | new selected audio device [device:%o]', - device); - - logger.debug('enableMic() | calling getUserMedia()'); + throw new Error('no webcam devices'); const stream = await navigator.mediaDevices.getUserMedia( { - audio : { - deviceId : { ideal: deviceId } - } - } - ); - - track = stream.getAudioTracks()[0]; - - this._micProducer = await this._sendTransport.produce( - { - track, - codecOptions : + video : { - opusStereo : 1, - opusDtx : 1 - }, - appData : - { source: 'mic' } + deviceId : { ideal: videoDeviceId }, + ...VIDEO_CONSTRAINS[resolution] + } }); + ([ track ] = stream.getVideoTracks()); + + let producer; + + 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; + + producer = await this._sendTransport.produce( + { + track, + encodings, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'extravideo' + } + }); + } + else + { + producer = await this._sendTransport.produce({ + track, + appData : + { + source : 'extravideo' + } + }); + } + + this._extraVideoProducers.set(producer.id, producer); + store.dispatch(producerActions.addProducer( { - 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] + id : producer.id, + deviceLabel : device.label, + source : 'extravideo', + paused : producer.paused, + track : producer.track, + rtpParameters : producer.rtpParameters, + codec : producer.rtpParameters.codecs[0].mimeType.split('/')[1] })); - store.dispatch(settingsActions.setSelectedAudioDevice(deviceId)); + // store.dispatch(settingsActions.setSelectedWebcamDevice(deviceId)); - await this._updateAudioDevices(); + await this._updateWebcams(); - this._micProducer.on('transportclose', () => + producer.on('transportclose', () => { - this._micProducer = null; + this._extraVideoProducers.delete(producer.id); + + producer = null; }); - this._micProducer.on('trackended', () => + producer.on('trackended', () => { store.dispatch(requestActions.notify( { type : 'error', text : intl.formatMessage({ - id : 'devices.microphoneDisconnected', - defaultMessage : 'Microphone disconnected' + id : 'devices.cameraDisconnected', + defaultMessage : 'Camera disconnected' }) })); - this.disableMic() + this.disableExtraVideo(producer.id) .catch(() => {}); }); - this._micProducer.volume = 0; - - const harkStream = new MediaStream(); - - harkStream.addTrack(track); - - if (!harkStream.getAudioTracks()[0]) - throw new Error('enableMic(): given stream has no audio track'); - - if (this._hark != null) - this._hark.stop(); - - this._hark = hark(harkStream, { play: false }); - - // eslint-disable-next-line no-unused-vars - this._hark.on('volume_change', (dBs, threshold) => - { - // The exact formula to convert from dBs (-100..0) to linear (0..1) is: - // Math.pow(10, dBs / 20) - // However it does not produce a visually useful output, so let exaggerate - // 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 (this._micProducer && volume !== this._micProducer.volume) - { - this._micProducer.volume = volume; - - store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); - } - }); + logger.debug('addExtraVideo() succeeded'); } catch (error) { - logger.error('enableMic() failed:%o', error); + logger.error('addExtraVideo() [error:"%o"]', error); store.dispatch(requestActions.notify( { type : 'error', text : intl.formatMessage({ - id : 'devices.microphoneError', - defaultMessage : 'An error occurred while accessing your microphone' + id : 'devices.cameraError', + defaultMessage : 'An error occurred while accessing your camera' }) })); if (track) track.stop(); - } store.dispatch( - meActions.setAudioInProgress(false)); + meActions.setWebcamInProgress(false)); } async disableMic() @@ -2608,22 +3588,16 @@ export default class RoomClient store.dispatch(meActions.setAudioInProgress(false)); } - async enableScreenSharing() + async updateScreenSharing({ + start = false, + newResolution = null, + newFrameRate = null + } = {}) { - if (this._screenSharingProducer) - return; - - if (!this._mediasoupDevice.canProduce('video')) - { - logger.error('enableScreenSharing() | cannot produce video'); - - return; - } + logger.debug('updateScreenSharing() [start:"%s"]', start); let track; - store.dispatch(meActions.setScreenShareInProgress(true)); - try { const available = this._screenSharing.isScreenShareAvailable(); @@ -2631,33 +3605,46 @@ export default class RoomClient if (!available) throw new Error('screen sharing not available'); - logger.debug('enableScreenSharing() | calling getUserMedia()'); + if (!this._mediasoupDevice.canProduce('video')) + throw new Error('cannot produce video'); - const stream = await this._screenSharing.start({ - width : 1920, - height : 1080, - frameRate : 5 - }); + if (newResolution) + store.dispatch(settingsActions.setScreenSharingResolution(newResolution)); - track = stream.getVideoTracks()[0]; + if (newFrameRate) + store.dispatch(settingsActions.setScreenSharingFrameRate(newFrameRate)); - if (this._useSharingSimulcast) + store.dispatch(meActions.setScreenShareInProgress(true)); + + const { + screenSharingResolution, + screenSharingFrameRate + } = store.getState().settings; + + if (start) { - // If VP9 is the only available video codec then use SVC. - const firstVideoCodec = this._mediasoupDevice - .rtpCapabilities - .codecs - .find((c) => c.kind === 'video'); + const stream = await this._screenSharing.start({ + ...VIDEO_CONSTRAINS[screenSharingResolution], + frameRate : screenSharingFrameRate + }); - let encodings; + ([ track ] = stream.getVideoTracks()); - if (firstVideoCodec.mimeType.toLowerCase() === 'video/vp9') + if (this._useSharingSimulcast) { - encodings = VIDEO_SVC_ENCODINGS; - } - else - { - if ('simulcastEncodings' in window.config) + // 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 })); @@ -2667,69 +3654,77 @@ export default class RoomClient encodings = VIDEO_SIMULCAST_ENCODINGS .map((encoding) => ({ ...encoding, dtx: true })); } - } - this._screenSharingProducer = await this._sendTransport.produce( - { - track, - encodings, - codecOptions : + this._screenSharingProducer = await this._sendTransport.produce( { - videoGoogleStartBitrate : 1000 - }, - appData : + track, + encodings, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'screen' + } + }); + } + else + { + this._screenSharingProducer = await this._sendTransport.produce({ + track, + appData : { source : 'screen' } }); - } - else - { - this._screenSharingProducer = await this._sendTransport.produce({ - track, - appData : + } + + store.dispatch(producerActions.addProducer( { - source : 'screen' - } - }); - } - - store.dispatch(producerActions.addProducer( - { - 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] - })); - - this._screenSharingProducer.on('transportclose', () => - { - this._screenSharingProducer = null; - }); - - this._screenSharingProducer.on('trackended', () => - { - store.dispatch(requestActions.notify( - { - type : 'error', - text : intl.formatMessage({ - id : 'devices.screenSharingDisconnected', - defaultMessage : 'Screen sharing disconnected' - }) + 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] })); - this.disableScreenSharing() - .catch(() => {}); - }); + this._screenSharingProducer.on('transportclose', () => + { + this._screenSharingProducer = null; + }); - logger.debug('enableScreenSharing() succeeded'); + this._screenSharingProducer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.screenSharingDisconnected', + defaultMessage : 'Screen sharing disconnected' + }) + })); + + this.disableScreenSharing(); + }); + } + else if (this._screenSharingProducer) + { + ({ track } = this._screenSharingProducer); + + await track.applyConstraints( + { + ...VIDEO_CONSTRAINS[screenSharingResolution], + frameRate : screenSharingFrameRate + } + ); + } } catch (error) { - logger.error('enableScreenSharing() failed: %o', error); + logger.error('updateScreenSharing() [error:"%o"]', error); store.dispatch(requestActions.notify( { @@ -2773,155 +3768,40 @@ export default class RoomClient this._screenSharingProducer = null; + this._screenSharing.stop(); + store.dispatch(meActions.setScreenShareInProgress(false)); } - async enableWebcam() + async disableExtraVideo(id) { + logger.debug('disableExtraVideo()'); - if (this._webcamProducer) + const producer = this._extraVideoProducers.get(id); + + if (!producer) return; - if (!this._mediasoupDevice.canProduce('video')) - { - logger.error('enableWebcam() | cannot produce video'); + store.dispatch(meActions.setWebcamInProgress(true)); - return; - } - - let track; + producer.close(); store.dispatch( - meActions.setWebcamInProgress(true)); + producerActions.removeProducer(id)); try { - const deviceId = await this._getWebcamDeviceId(); - - const device = this._webcams[deviceId]; - const resolution = store.getState().settings.resolution; - - if (!device) - throw new Error('no webcam devices'); - - logger.debug( - '_setWebcamProducer() | new selected webcam [device:%o]', - device); - - logger.debug('_setWebcamProducer() | calling getUserMedia()'); - - const stream = await navigator.mediaDevices.getUserMedia( - { - video : - { - deviceId : { ideal: deviceId }, - ...VIDEO_CONSTRAINS[resolution] - } - }); - - track = stream.getVideoTracks()[0]; - - 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, - codecOptions : - { - videoGoogleStartBitrate : 1000 - }, - appData : - { - source : 'webcam' - } - }); - } - else - { - this._webcamProducer = await this._sendTransport.produce({ - track, - appData : - { - source : 'webcam' - } - }); - } - - store.dispatch(producerActions.addProducer( - { - 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(settingsActions.setSelectedWebcamDevice(deviceId)); - - await this._updateWebcams(); - - this._webcamProducer.on('transportclose', () => - { - this._webcamProducer = null; - }); - - this._webcamProducer.on('trackended', () => - { - store.dispatch(requestActions.notify( - { - type : 'error', - text : intl.formatMessage({ - id : 'devices.cameraDisconnected', - defaultMessage : 'Camera disconnected' - }) - })); - - this.disableWebcam() - .catch(() => {}); - }); - - logger.debug('_setWebcamProducer() succeeded'); + await this.sendRequest( + 'closeProducer', { producerId: id }); } catch (error) { - logger.error('_setWebcamProducer() failed:%o', error); - - store.dispatch(requestActions.notify( - { - type : 'error', - text : intl.formatMessage({ - id : 'devices.cameraError', - defaultMessage : 'An error occurred while accessing your camera' - }) - })); - - if (track) - track.stop(); + logger.error('disableWebcam() [error:"%o"]', error); } - store.dispatch( - meActions.setWebcamInProgress(false)); + this._extraVideoProducers.delete(id); + + store.dispatch(meActions.setWebcamInProgress(false)); } async disableWebcam() @@ -2953,6 +3833,16 @@ export default class RoomClient store.dispatch(meActions.setWebcamInProgress(false)); } + async _setNoiseThreshold(threshold) + { + logger.debug('_setNoiseThreshold() [threshold:"%s"]', threshold); + + this._hark.setThreshold(threshold); + + store.dispatch( + settingsActions.setNoiseThreshold(threshold)); + } + async _updateAudioDevices() { logger.debug('_updateAudioDevices()'); @@ -2979,7 +3869,7 @@ export default class RoomClient } catch (error) { - logger.error('_updateAudioDevices() failed:%o', error); + logger.error('_updateAudioDevices() [error:"%o"]', error); } } @@ -3009,7 +3899,7 @@ export default class RoomClient } catch (error) { - logger.error('_updateWebcams() failed:%o', error); + logger.error('_updateWebcams() [error:"%o"]', error); } } @@ -3036,7 +3926,7 @@ export default class RoomClient } catch (error) { - logger.error('_getAudioDeviceId() failed:%o', error); + logger.error('_getAudioDeviceId() [error:"%o"]', error); } } @@ -3063,7 +3953,38 @@ export default class RoomClient } catch (error) { - logger.error('_getWebcamDeviceId() failed:%o', error); + logger.error('_getWebcamDeviceId() [error:"%o"]', error); } } + + async _updateAudioOutputDevices() + { + logger.debug('_updateAudioOutputDevices()'); + + // Reset the list. + this._audioOutputDevices = {}; + + try + { + logger.debug('_updateAudioOutputDevices() | calling enumerateDevices()'); + + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) + { + if (device.kind !== 'audiooutput') + continue; + + this._audioOutputDevices[device.deviceId] = device; + } + + store.dispatch( + meActions.setAudioOutputDevices(this._audioOutputDevices)); + } + catch (error) + { + logger.error('_updateAudioOutputDevices() [error:"%o"]', error); + } + } + } diff --git a/app/src/ScreenShare.js b/app/src/ScreenShare.js index 180fe2a..f0e420b 100644 --- a/app/src/ScreenShare.js +++ b/app/src/ScreenShare.js @@ -213,6 +213,8 @@ export default class ScreenShare { if (isElectron()) return new ElectronScreenShare(); + else if (device.platform !== 'desktop') + return new DefaultScreenShare(); else { switch (device.flag) @@ -224,11 +226,17 @@ export default class ScreenShare else return new DisplayMediaScreenShare(); } - case 'chrome': + case 'safari': { - return new DisplayMediaScreenShare(); + if (device.version >= 13.0) + return new DisplayMediaScreenShare(); + else + return new DefaultScreenShare(); } - case 'msedge': + case 'chrome': + case 'chromium': + case 'opera': + case 'edge': { return new DisplayMediaScreenShare(); } diff --git a/app/src/Spotlights.js b/app/src/Spotlights.js index a680f5e..9fc2301 100644 --- a/app/src/Spotlights.js +++ b/app/src/Spotlights.js @@ -12,6 +12,7 @@ export default class Spotlights extends EventEmitter this._signalingSocket = signalingSocket; this._maxSpotlights = maxSpotlights; this._peerList = []; + this._unmutablePeerList = []; this._selectedSpotlights = []; this._currentSpotlights = []; this._started = false; @@ -45,12 +46,80 @@ export default class Spotlights extends EventEmitter } } + getNextAsSelected(peerId) + { + let newSelectedPeer = null; + + if (peerId == null && this._unmutablePeerList.length > 0) + { + peerId = this._unmutablePeerList[0]; + } + + if (peerId != null && this._currentSpotlights.length < this._unmutablePeerList.length) + { + const oldIndex = this._unmutablePeerList.indexOf(peerId); + + let index = oldIndex; + + index++; + for (let i = 0; i < this._unmutablePeerList.length; i++) + { + if (index >= this._unmutablePeerList.length) + { + index = 0; + } + newSelectedPeer = this._unmutablePeerList[index]; + if (!this._currentSpotlights.includes(newSelectedPeer)) + { + break; + } + index++; + } + } + + return newSelectedPeer; + } + + getPrevAsSelected(peerId) + { + let newSelectedPeer = null; + + if (peerId == null && this._unmutablePeerList.length > 0) + { + peerId = this._unmutablePeerList[0]; + } + + if (peerId != null && this._currentSpotlights.length < this._unmutablePeerList.length) + { + const oldIndex = this._unmutablePeerList.indexOf(peerId); + + let index = oldIndex; + + index--; + for (let i = 0; i < this._unmutablePeerList.length; i++) + { + if (index < 0) + { + index = this._unmutablePeerList.length - 1; + } + newSelectedPeer = this._unmutablePeerList[index]; + if (!this._currentSpotlights.includes(newSelectedPeer)) + { + break; + } + index--; + } + } + + return newSelectedPeer; + } + setPeerSpotlight(peerId) { logger.debug('setPeerSpotlight() [peerId:"%s"]', peerId); const index = this._selectedSpotlights.indexOf(peerId); - + if (index !== -1) { this._selectedSpotlights = []; @@ -95,16 +164,26 @@ export default class Spotlights extends EventEmitter }); } + clearSpotlights() + { + this._started = false; + + this._peerList = []; + this._selectedSpotlights = []; + this._currentSpotlights = []; + } + _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); + this._unmutablePeerList.push(id); if (this._started) this._spotlightsUpdated(); @@ -116,19 +195,10 @@ export default class Spotlights extends EventEmitter logger.debug( 'room "peerClosed" event [peerId:%o]', id); - let index = this._peerList.indexOf(id); + this._peerList = this._peerList.filter((peer) => peer !== id); + this._unmutablePeerList = this._unmutablePeerList.filter((peer) => peer !== 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); - } + this._selectedSpotlights = this._selectedSpotlights.filter((peer) => peer !== id); if (this._started) this._spotlightsUpdated(); diff --git a/app/src/__tests__/Room.spec.js b/app/src/__tests__/Room.spec.js index a866e06..bd62322 100644 --- a/app/src/__tests__/Room.spec.js +++ b/app/src/__tests__/Room.spec.js @@ -31,6 +31,8 @@ beforeEach(() => me : { audioDevices : null, audioInProgress : false, + audioOutputDevices : null, + audioOutputInProgress : false, canSendMic : false, canSendWebcam : false, canShareFiles : false, @@ -40,8 +42,8 @@ beforeEach(() => loggedIn : false, loginEnabled : true, picture : null, - raiseHand : false, - raiseHandInProgress : false, + raisedHand : false, + raisedHandInProgress : false, screenShareInProgress : false, webcamDevices : null, webcamInProgress : false @@ -72,11 +74,12 @@ beforeEach(() => windowConsumer : null }, settings : { - advancedMode : true, - displayName : 'Jest Tester', - resolution : 'ultra', - selectedAudioDevice : 'default', - selectedWebcam : 'soifjsiajosjfoi' + advancedMode : true, + displayName : 'Jest Tester', + resolution : 'ultra', + selectedAudioDevice : 'default', + selectedAudioOutputDevice : 'default', + selectedWebcam : 'soifjsiajosjfoi' }, toolarea : { currentToolTab : 'chat', diff --git a/app/src/actions/chatActions.js b/app/src/actions/chatActions.js index c38d92d..0f9f066 100644 --- a/app/src/actions/chatActions.js +++ b/app/src/actions/chatActions.js @@ -9,9 +9,14 @@ export const addResponseMessage = (message) => type : 'ADD_NEW_RESPONSE_MESSAGE', payload : { message } }); - + export const addChatHistory = (chatHistory) => ({ type : 'ADD_CHAT_HISTORY', payload : { chatHistory } + }); + +export const clearChat = () => + ({ + type : 'CLEAR_CHAT' }); \ No newline at end of file diff --git a/app/src/actions/consumerActions.js b/app/src/actions/consumerActions.js index b8460a6..659a609 100644 --- a/app/src/actions/consumerActions.js +++ b/app/src/actions/consumerActions.js @@ -10,6 +10,11 @@ export const removeConsumer = (consumerId, peerId) => payload : { consumerId, peerId } }); +export const clearConsumers = () => + ({ + type : 'CLEAR_CONSUMERS' + }); + export const setConsumerPaused = (consumerId, originator) => ({ type : 'SET_CONSUMER_PAUSED', @@ -35,12 +40,10 @@ export const setConsumerPreferredLayers = (consumerId, spatialLayer, temporalLay }); export const setConsumerPriority = (consumerId, priority) => - { - return { - type : 'SET_CONSUMER_PRIORITY', - payload : { consumerId, priority } - }; - }; + ({ + type : 'SET_CONSUMER_PRIORITY', + payload : { consumerId, priority } + }); export const setConsumerTrack = (consumerId, track) => ({ diff --git a/app/src/actions/fileActions.js b/app/src/actions/fileActions.js index f9277d6..b5d90e5 100644 --- a/app/src/actions/fileActions.js +++ b/app/src/actions/fileActions.js @@ -32,4 +32,9 @@ export const setFileDone = (magnetUri, sharedFiles) => ({ type : 'SET_FILE_DONE', payload : { magnetUri, sharedFiles } + }); + +export const clearFiles = () => + ({ + type : 'CLEAR_FILES' }); \ No newline at end of file diff --git a/app/src/actions/meActions.js b/app/src/actions/meActions.js index b704880..7e7d3de 100644 --- a/app/src/actions/meActions.js +++ b/app/src/actions/meActions.js @@ -4,12 +4,30 @@ export const setMe = ({ peerId, loginEnabled }) => payload : { peerId, loginEnabled } }); +export const setBrowser = (browser) => + ({ + type : 'SET_BROWSER', + payload : { browser } + }); + export const loggedIn = (flag) => ({ type : 'LOGGED_IN', payload : { flag } }); +export const addRole = (role) => + ({ + type : 'ADD_ROLE', + payload : { role } + }); + +export const removeRole = (role) => + ({ + type : 'REMOVE_ROLE', + payload : { role } + }); + export const setPicture = (picture) => ({ type : 'SET_PICTURE', @@ -17,11 +35,11 @@ export const setPicture = (picture) => }); export const setMediaCapabilities = ({ - canSendMic, - canSendWebcam, - canShareScreen, - canShareFiles - }) => + canSendMic, + canSendWebcam, + canShareScreen, + canShareFiles +}) => ({ type : 'SET_MEDIA_CAPABILITIES', payload : { canSendMic, canSendWebcam, canShareScreen, canShareFiles } @@ -33,15 +51,21 @@ export const setAudioDevices = (devices) => payload : { devices } }); +export const setAudioOutputDevices = (devices) => + ({ + type : 'SET_AUDIO_OUTPUT_DEVICES', + payload : { devices } + }); + export const setWebcamDevices = (devices) => ({ type : 'SET_WEBCAM_DEVICES', payload : { devices } }); -export const setMyRaiseHandState = (flag) => +export const setRaisedHand = (flag) => ({ - type : 'SET_MY_RAISE_HAND_STATE', + type : 'SET_RAISED_HAND', payload : { flag } }); @@ -50,22 +74,28 @@ export const setAudioInProgress = (flag) => type : 'SET_AUDIO_IN_PROGRESS', payload : { flag } }); - + +export const setAudioOutputInProgress = (flag) => + ({ + type : 'SET_AUDIO_OUTPUT_IN_PROGRESS', + payload : { flag } + }); + export const setWebcamInProgress = (flag) => ({ type : 'SET_WEBCAM_IN_PROGRESS', payload : { flag } }); - + export const setScreenShareInProgress = (flag) => ({ type : 'SET_SCREEN_SHARE_IN_PROGRESS', payload : { flag } }); -export const setMyRaiseHandStateInProgress = (flag) => +export const setRaisedHandInProgress = (flag) => ({ - type : 'SET_MY_RAISE_HAND_STATE_IN_PROGRESS', + type : 'SET_RAISED_HAND_IN_PROGRESS', payload : { flag } }); @@ -74,3 +104,15 @@ export const setDisplayNameInProgress = (flag) => type : 'SET_DISPLAY_NAME_IN_PROGRESS', payload : { flag } }); + +export const setIsSpeaking = (flag) => + ({ + type : 'SET_IS_SPEAKING', + payload : { flag } + }); + +export const setAutoMuted = (flag) => + ({ + type : 'SET_AUTO_MUTED', + payload : { flag } + }); diff --git a/app/src/actions/peerActions.js b/app/src/actions/peerActions.js index 4caca32..7cbea6f 100644 --- a/app/src/actions/peerActions.js +++ b/app/src/actions/peerActions.js @@ -10,6 +10,11 @@ export const removePeer = (peerId) => payload : { peerId } }); +export const clearPeers = () => + ({ + type : 'CLEAR_PEERS' + }); + export const setPeerDisplayName = (displayName, peerId) => ({ type : 'SET_PEER_DISPLAY_NAME', @@ -21,23 +26,29 @@ export const setPeerVideoInProgress = (peerId, flag) => type : 'SET_PEER_VIDEO_IN_PROGRESS', payload : { peerId, flag } }); - + export const setPeerAudioInProgress = (peerId, flag) => ({ type : 'SET_PEER_AUDIO_IN_PROGRESS', payload : { peerId, flag } }); - + export const setPeerScreenInProgress = (peerId, flag) => ({ type : 'SET_PEER_SCREEN_IN_PROGRESS', payload : { peerId, flag } }); -export const setPeerRaiseHandState = (peerId, raiseHandState) => +export const setPeerRaisedHand = (peerId, raisedHand, raisedHandTimestamp) => ({ - type : 'SET_PEER_RAISE_HAND_STATE', - payload : { peerId, raiseHandState } + type : 'SET_PEER_RAISED_HAND', + payload : { peerId, raisedHand, raisedHandTimestamp } + }); + +export const setPeerRaisedHandInProgress = (peerId, flag) => + ({ + type : 'SET_PEER_RAISED_HAND_IN_PROGRESS', + payload : { peerId, flag } }); export const setPeerPicture = (peerId, picture) => @@ -45,3 +56,39 @@ export const setPeerPicture = (peerId, picture) => type : 'SET_PEER_PICTURE', payload : { peerId, picture } }); + +export const addPeerRole = (peerId, role) => + ({ + type : 'ADD_PEER_ROLE', + payload : { peerId, role } + }); + +export const removePeerRole = (peerId, role) => + ({ + type : 'REMOVE_PEER_ROLE', + payload : { peerId, role } + }); + +export const setPeerKickInProgress = (peerId, flag) => + ({ + type : 'SET_PEER_KICK_IN_PROGRESS', + payload : { peerId, flag } + }); + +export const setMutePeerInProgress = (peerId, flag) => + ({ + type : 'STOP_PEER_AUDIO_IN_PROGRESS', + payload : { peerId, flag } + }); + +export const setStopPeerVideoInProgress = (peerId, flag) => + ({ + type : 'STOP_PEER_VIDEO_IN_PROGRESS', + payload : { peerId, flag } + }); + +export const setStopPeerScreenSharingInProgress = (peerId, flag) => + ({ + type : 'STOP_PEER_SCREEN_SHARING_IN_PROGRESS', + payload : { peerId, flag } + }); diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 560c77d..a73e18a 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -40,6 +40,12 @@ export const setSignInRequired = (signInRequired) => payload : { signInRequired } }); +export const setOverRoomLimit = (overRoomLimit) => + ({ + type : 'SET_OVER_ROOM_LIMIT', + payload : { overRoomLimit } + }); + export const setAccessCode = (accessCode) => ({ type : 'SET_ACCESS_CODE', @@ -52,13 +58,37 @@ export const setJoinByAccessCode = (joinByAccessCode) => payload : { joinByAccessCode } }); -export const setSettingsOpen = ({ settingsOpen }) => +export const setSettingsOpen = (settingsOpen) => ({ type : 'SET_SETTINGS_OPEN', payload : { settingsOpen } }); -export const setLockDialogOpen = ({ lockDialogOpen }) => +export const setExtraVideoOpen = (extraVideoOpen) => + ({ + type : 'SET_EXTRA_VIDEO_OPEN', + payload : { extraVideoOpen } + }); + +export const setHelpOpen = (helpOpen) => + ({ + type : 'SET_HELP_OPEN', + payload : { helpOpen } + }); + +export const setAboutOpen = (aboutOpen) => + ({ + type : 'SET_ABOUT_OPEN', + payload : { aboutOpen } + }); + +export const setSettingsTab = (tab) => + ({ + type : 'SET_SETTINGS_TAB', + payload : { tab } + }); + +export const setLockDialogOpen = (lockDialogOpen) => ({ type : 'SET_LOCK_DIALOG_OPEN', payload : { lockDialogOpen } @@ -100,6 +130,11 @@ export const setSpotlights = (spotlights) => payload : { spotlights } }); +export const clearSpotlights = () => + ({ + type : 'CLEAR_SPOTLIGHTS' + }); + export const toggleJoined = () => ({ type : 'TOGGLE_JOINED' @@ -109,4 +144,58 @@ export const toggleConsumerFullscreen = (consumerId) => ({ type : 'TOGGLE_FULLSCREEN_CONSUMER', payload : { consumerId } - }); \ No newline at end of file + }); + +export const setLobbyPeersPromotionInProgress = (flag) => + ({ + type : 'SET_LOBBY_PEERS_PROMOTION_IN_PROGRESS', + payload : { flag } + }); + +export const setMuteAllInProgress = (flag) => + ({ + type : 'MUTE_ALL_IN_PROGRESS', + payload : { flag } + }); + +export const setStopAllVideoInProgress = (flag) => + ({ + type : 'STOP_ALL_VIDEO_IN_PROGRESS', + payload : { flag } + }); + +export const setStopAllScreenSharingInProgress = (flag) => + ({ + type : 'STOP_ALL_SCREEN_SHARING_IN_PROGRESS', + payload : { flag } + }); + +export const setCloseMeetingInProgress = (flag) => + ({ + type : 'CLOSE_MEETING_IN_PROGRESS', + payload : { flag } + }); + +export const setClearChatInProgress = (flag) => + ({ + type : 'CLEAR_CHAT_IN_PROGRESS', + payload : { flag } + }); + +export const setClearFileSharingInProgress = (flag) => + ({ + type : 'CLEAR_FILE_SHARING_IN_PROGRESS', + payload : { flag } + }); + +export const setRoomPermissions = (roomPermissions) => + ({ + type : 'SET_ROOM_PERMISSIONS', + payload : { roomPermissions } + }); + +export const setAllowWhenRoleMissing = (allowWhenRoleMissing) => + ({ + type : 'SET_ALLOW_WHEN_ROLE_MISSING', + payload : { allowWhenRoleMissing } + }); diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 79b5ef2..46be0e8 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -4,6 +4,12 @@ export const setSelectedAudioDevice = (deviceId) => payload : { deviceId } }); +export const setSelectedAudioOutputDevice = (deviceId) => + ({ + type : 'CHANGE_AUDIO_OUTPUT_DEVICE', + payload : { deviceId } + }); + export const setSelectedWebcamDevice = (deviceId) => ({ type : 'CHANGE_WEBCAM', @@ -16,6 +22,24 @@ export const setVideoResolution = (resolution) => payload : { resolution } }); +export const setVideoFrameRate = (frameRate) => + ({ + type : 'SET_VIDEO_FRAME_RATE', + payload : { frameRate } + }); + +export const setScreenSharingResolution = (screenSharingResolution) => + ({ + type : 'SET_SCREEN_SHARING_RESOLUTION', + payload : { screenSharingResolution } + }); + +export const setScreenSharingFrameRate = (screenSharingFrameRate) => + ({ + type : 'SET_SCREEN_SHARING_FRAME_RATE', + payload : { screenSharingFrameRate } + }); + export const setDisplayName = (displayName) => ({ type : 'SET_DISPLAY_NAME', @@ -32,6 +56,67 @@ export const togglePermanentTopBar = () => type : 'TOGGLE_PERMANENT_TOPBAR' }); +export const toggleButtonControlBar = () => + ({ + type : 'TOGGLE_BUTTON_CONTROL_BAR' + }); + +export const toggleDrawerOverlayed = () => + ({ + type : 'TOGGLE_DRAWER_OVERLAYED' + }); + +export const toggleShowNotifications = () => + ({ + type : 'TOGGLE_SHOW_NOTIFICATIONS' + }); + +export const setEchoCancellation = (echoCancellation) => + ({ + type : 'SET_ECHO_CANCELLATION', + payload : { echoCancellation } + }); + +export const setAutoGainControl = (autoGainControl) => + ({ + type : 'SET_AUTO_GAIN_CONTROL', + payload : { autoGainControl } + }); + +export const setNoiseSuppression = (noiseSuppression) => + ({ + type : 'SET_NOISE_SUPPRESSION', + payload : { noiseSuppression } + }); + +export const setVoiceActivatedUnmute = (voiceActivatedUnmute) => + ({ + type : 'SET_VOICE_ACTIVATED_UNMUTE', + payload : { voiceActivatedUnmute } + }); + +export const setNoiseThreshold = (noiseThreshold) => + ({ + type : 'SET_NOISE_THRESHOLD', + payload : { noiseThreshold } + }); + +export const setDefaultAudio = (audio) => + ({ + type : 'SET_DEFAULT_AUDIO', + payload : { audio } + }); + +export const toggleHiddenControls = () => + ({ + type : 'TOGGLE_HIDDEN_CONTROLS' + }); + +export const toggleNotificationSounds = () => + ({ + type : 'TOGGLE_NOTIFICATION_SOUNDS' + }); + export const setLastN = (lastN) => ({ type : 'SET_LAST_N', diff --git a/app/src/actions/transportActions.js b/app/src/actions/transportActions.js new file mode 100644 index 0000000..46b901e --- /dev/null +++ b/app/src/actions/transportActions.js @@ -0,0 +1,5 @@ +export const addTransportStats = (transport, type) => + ({ + type : 'ADD_TRANSPORT_STATS', + payload : { transport, type } + }); \ No newline at end of file diff --git a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js index 94d04d2..e955b53 100644 --- a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js +++ b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js @@ -5,74 +5,20 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; +import IconButton from '@material-ui/core/IconButton'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import Avatar from '@material-ui/core/Avatar'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import PromoteIcon from '@material-ui/icons/OpenInBrowser'; import Tooltip from '@material-ui/core/Tooltip'; -const styles = (theme) => +const styles = () => ({ root : - { - padding : theme.spacing(1), - width : '100%', - overflow : 'hidden', - cursor : 'auto', - display : 'flex' - }, - avatar : - { - borderRadius : '50%', - height : '2rem' - }, - peerInfo : - { - fontSize : '1rem', - border : 'none', - display : 'flex', - paddingLeft : theme.spacing(1), - flexGrow : 1, - alignItems : 'center' - }, - controls : - { - float : 'right', - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center' - }, - button : - { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundColor : 'rgba(0, 0, 0, 0.5)', - cursor : 'pointer', - transitionProperty : 'opacity, background-color', - transitionDuration : '0.15s', - width : 'var(--media-control-button-size)', - height : 'var(--media-control-button-size)', - opacity : 0.85, - '&:hover' : - { - opacity : 1 - }, - '&.disabled' : - { - pointerEvents : 'none', - backgroundColor : 'var(--media-control-botton-disabled)' - }, - '&.promote' : - { - backgroundColor : 'var(--media-control-botton-on)' - } - }, - ListItem : { alignItems : 'center' } @@ -83,6 +29,8 @@ const ListLobbyPeer = (props) => const { roomClient, peer, + promotionInProgress, + canPromote, classes } = props; @@ -91,8 +39,8 @@ const ListLobbyPeer = (props) => const picture = peer.picture || EmptyAvatar; return ( - defaultMessage : 'Click to let them in' })} > - { e.stopPropagation(); @@ -120,7 +71,7 @@ const ListLobbyPeer = (props) => }} > - + ); @@ -128,27 +79,41 @@ const ListLobbyPeer = (props) => ListLobbyPeer.propTypes = { - roomClient : PropTypes.any.isRequired, - advancedMode : PropTypes.bool, - peer : PropTypes.object.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + advancedMode : PropTypes.bool, + peer : PropTypes.object.isRequired, + promotionInProgress : PropTypes.bool.isRequired, + canPromote : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired }; -const mapStateToProps = (state, { id }) => +const makeMapStateToProps = (initialState, { id }) => { - return { - peer : state.lobbyPeers[id] + const hasPermission = makePermissionSelector(permissions.PROMOTE_PEER); + + const mapStateToProps = (state) => + { + return { + peer : state.lobbyPeers[id], + promotionInProgress : state.room.lobbyPeersPromotionInProgress, + canPromote : hasPermission(state) + }; }; + + return mapStateToProps; }; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( + prev.room === next.room && + prev.peers === next.peers && // For checking permissions + prev.me.roles === next.me.roles && prev.lobbyPeers === next.lobbyPeers ); } diff --git a/app/src/components/AccessControl/LockDialog/LockDialog.js b/app/src/components/AccessControl/LockDialog/LockDialog.js index 57dd0ea..2bef00c 100644 --- a/app/src/components/AccessControl/LockDialog/LockDialog.js +++ b/app/src/components/AccessControl/LockDialog/LockDialog.js @@ -1,8 +1,10 @@ import React from 'react'; import { connect } from 'react-redux'; import { - lobbyPeersKeySelector + lobbyPeersKeySelector, + makePermissionSelector } from '../../Selectors'; +import { permissions } from '../../../permissions'; import * as appPropTypes from '../../appPropTypes'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; @@ -15,14 +17,6 @@ import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogContentText from '@material-ui/core/DialogContentText'; import Button from '@material-ui/core/Button'; -// import FormLabel from '@material-ui/core/FormLabel'; -// import FormControl from '@material-ui/core/FormControl'; -// import FormGroup from '@material-ui/core/FormGroup'; -// import FormControlLabel from '@material-ui/core/FormControlLabel'; -// import Checkbox from '@material-ui/core/Checkbox'; -// import InputLabel from '@material-ui/core/InputLabel'; -// import OutlinedInput from '@material-ui/core/OutlinedInput'; -// import Switch from '@material-ui/core/Switch'; import List from '@material-ui/core/List'; import ListSubheader from '@material-ui/core/ListSubheader'; import ListLobbyPeer from './ListLobbyPeer'; @@ -59,11 +53,11 @@ const styles = (theme) => }); const LockDialog = ({ - // roomClient, + roomClient, room, handleCloseLockDialog, - // handleAccessCode, lobbyPeers, + canPromote, classes }) => { @@ -71,7 +65,7 @@ const LockDialog = ({ handleCloseLockDialog({ lockDialogOpen: false })} + onClose={() => handleCloseLockDialog(false)} classes={{ paper : classes.dialogPaper }} @@ -82,56 +76,8 @@ const LockDialog = ({ defaultMessage='Lobby administration' /> - {/* - - Room lock - - - { - if (room.locked) - { - roomClient.unlockRoom(); - } - else - { - roomClient.lockRoom(); - } - }} - />} - label='Lock' - /> - TODO: access code - roomClient.setJoinByAccessCode(event.target.checked) - } - />} - label='Join by Access code' - /> - - handleAccessCode(event.target.value)} - > - - - - - - */} { lobbyPeers.length > 0 ? - @@ -160,7 +106,21 @@ const LockDialog = ({ } - + + + + ); +}; + +About.propTypes = +{ + roomClient : PropTypes.object.isRequired, + aboutOpen : PropTypes.bool.isRequired, + handleCloseAbout : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + aboutOpen : state.room.aboutOpen + }); + +const mapDispatchToProps = { + handleCloseAbout : roomActions.setAboutOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.aboutOpen === next.room.aboutOpen + ); + } + } +)(withStyles(styles)(About))); \ No newline at end of file diff --git a/app/src/components/Controls/ButtonControlBar.js b/app/src/components/Controls/ButtonControlBar.js new file mode 100644 index 0000000..2080603 --- /dev/null +++ b/app/src/components/Controls/ButtonControlBar.js @@ -0,0 +1,341 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { meProducersSelector } from '../Selectors'; +import { withStyles } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import classnames from 'classnames'; +import * as appPropTypes from '../appPropTypes'; +import { withRoomContext } from '../../RoomContext'; +import { useIntl } from 'react-intl'; +import Fab from '@material-ui/core/Fab'; +import Tooltip from '@material-ui/core/Tooltip'; +import MicIcon from '@material-ui/icons/Mic'; +import MicOffIcon from '@material-ui/icons/MicOff'; +import VideoIcon from '@material-ui/icons/Videocam'; +import VideoOffIcon from '@material-ui/icons/VideocamOff'; +import ScreenIcon from '@material-ui/icons/ScreenShare'; +import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; + +const styles = (theme) => + ({ + root : + { + position : 'fixed', + display : 'flex', + zIndex : 30, + [theme.breakpoints.up('md')] : + { + top : '50%', + transform : 'translate(0%, -50%)', + flexDirection : 'column', + justifyContent : 'center', + alignItems : 'center', + left : theme.spacing(1) + }, + [theme.breakpoints.down('sm')] : + { + flexDirection : 'row', + bottom : theme.spacing(1), + left : '50%', + transform : 'translate(-50%, -0%)' + } + }, + fab : + { + margin : theme.spacing(1) + }, + show : + { + opacity : 1, + transition : 'opacity .5s' + }, + hide : + { + opacity : 0, + transition : 'opacity .5s' + }, + move : + { + left : '30vw', + top : '50%', + transform : 'translate(0%, -50%)', + flexDirection : 'column', + justifyContent : 'center', + alignItems : 'center', + [theme.breakpoints.down('lg')] : + { + left : '40vw' + }, + [theme.breakpoints.down('md')] : + { + left : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + left : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + left : '90vw' + } + } + }); + +const ButtonControlBar = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + toolbarsVisible, + hiddenControls, + drawerOverlayed, + toolAreaOpen, + me, + micProducer, + webcamProducer, + screenProducer, + classes, + theme + } = props; + + let micState; + + let micTip; + + if (!me.canSendMic) + { + micState = 'unsupported'; + micTip = intl.formatMessage({ + id : 'device.audioUnsupported', + defaultMessage : 'Audio unsupported' + }); + } + else if (!micProducer) + { + micState = 'off'; + micTip = intl.formatMessage({ + id : 'device.activateAudio', + defaultMessage : 'Activate audio' + }); + } + else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) + { + micState = 'on'; + micTip = intl.formatMessage({ + id : 'device.muteAudio', + defaultMessage : 'Mute audio' + }); + } + else + { + micState = 'muted'; + micTip = intl.formatMessage({ + id : 'device.unMuteAudio', + defaultMessage : 'Unmute audio' + }); + } + + let webcamState; + + let webcamTip; + + if (!me.canSendWebcam) + { + webcamState = 'unsupported'; + webcamTip = intl.formatMessage({ + id : 'device.videoUnsupported', + defaultMessage : 'Video unsupported' + }); + } + else if (webcamProducer) + { + webcamState = 'on'; + webcamTip = intl.formatMessage({ + id : 'device.stopVideo', + defaultMessage : 'Stop video' + }); + } + else + { + webcamState = 'off'; + webcamTip = intl.formatMessage({ + id : 'device.startVideo', + defaultMessage : 'Start video' + }); + } + + let screenState; + + let screenTip; + + if (!me.canShareScreen) + { + screenState = 'unsupported'; + screenTip = intl.formatMessage({ + id : 'device.screenSharingUnsupported', + defaultMessage : 'Screen sharing not supported' + }); + } + else if (screenProducer) + { + screenState = 'on'; + screenTip = intl.formatMessage({ + id : 'device.stopScreenSharing', + defaultMessage : 'Stop screen sharing' + }); + } + else + { + screenState = 'off'; + screenTip = intl.formatMessage({ + id : 'device.startScreenSharing', + defaultMessage : 'Start screen sharing' + }); + } + + const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); + + return ( +
+ + + { + if (micState === 'off') + roomClient.updateMic({ start: true }); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + + + + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.updateWebcam({ start: true }); + }} + > + { webcamState === 'on' ? + + : + + } + + + { me.browser.platform !== 'mobile' && + + + { + if (screenState === 'off') + roomClient.updateScreenSharing({ start: true }); + else if (screenState === 'on') + roomClient.disableScreenSharing(); + }} + > + { screenState === 'on' || screenState === 'unsupported' ? + + :null + } + { screenState === 'off' ? + + :null + } + + + } +
+ ); +}; + +ButtonControlBar.propTypes = +{ + roomClient : PropTypes.any.isRequired, + toolbarsVisible : PropTypes.bool.isRequired, + hiddenControls : PropTypes.bool.isRequired, + drawerOverlayed : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, + me : appPropTypes.Me.isRequired, + micProducer : appPropTypes.Producer, + webcamProducer : appPropTypes.Producer, + screenProducer : appPropTypes.Producer, + classes : PropTypes.object.isRequired, + theme : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + toolbarsVisible : state.room.toolbarsVisible, + hiddenControls : state.settings.hiddenControls, + drawerOverlayed : state.settings.drawerOverlayed, + toolAreaOpen : state.toolarea.toolAreaOpen, + ...meProducersSelector(state), + me : state.me + }); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.settings.hiddenControls === next.settings.hiddenControls && + prev.settings.drawerOverlayed === next.settings.drawerOverlayed && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen && + prev.producers === next.producers && + prev.me === next.me + ); + } + } +)(withStyles(styles, { withTheme: true })(ButtonControlBar))); \ No newline at end of file diff --git a/app/src/components/Controls/ExtraVideo.js b/app/src/components/Controls/ExtraVideo.js new file mode 100644 index 0000000..f15b399 --- /dev/null +++ b/app/src/components/Controls/ExtraVideo.js @@ -0,0 +1,167 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as roomActions from '../../actions/roomActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import Select from '@material-ui/core/Select'; + +const styles = (theme) => + ({ + dialogPaper : + { + width : '30vw', + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + }, + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + } + }); + +const ExtraVideo = ({ + roomClient, + extraVideoOpen, + webcamDevices, + handleCloseExtraVideo, + classes +}) => +{ + const intl = useIntl(); + + const [ videoDevice, setVideoDevice ] = React.useState(''); + + const handleChange = (event) => + { + setVideoDevice(event.target.value); + }; + + let videoDevices; + + if (webcamDevices) + videoDevices = Object.values(webcamDevices); + else + videoDevices = []; + + return ( + handleCloseExtraVideo(false)} + classes={{ + paper : classes.dialogPaper + }} + > + + + +
+ + + + { videoDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectCamera', + defaultMessage : 'Select video device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectCamera', + defaultMessage : 'Unable to select video device' + }) + } + + +
+ + + +
+ ); +}; + +ExtraVideo.propTypes = +{ + roomClient : PropTypes.object.isRequired, + extraVideoOpen : PropTypes.bool.isRequired, + webcamDevices : PropTypes.object, + handleCloseExtraVideo : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + webcamDevices : state.me.webcamDevices, + extraVideoOpen : state.room.extraVideoOpen + }); + +const mapDispatchToProps = { + handleCloseExtraVideo : roomActions.setExtraVideoOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me.webcamDevices === next.me.webcamDevices && + prev.room.extraVideoOpen === next.room.extraVideoOpen + ); + } + } +)(withStyles(styles)(ExtraVideo))); \ No newline at end of file diff --git a/app/src/components/Controls/Help.js b/app/src/components/Controls/Help.js new file mode 100644 index 0000000..1058f2e --- /dev/null +++ b/app/src/components/Controls/Help.js @@ -0,0 +1,167 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as roomActions from '../../actions/roomActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; + +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const shortcuts=[ + { key: 'h', label: 'room.help', defaultMessage: 'Help' }, + { key: 'm', label: 'device.muteAudio', defaultMessage: 'Mute Audio' }, + { key: 'v', label: 'device.stopVideo', defaultMessage: 'Mute Video' }, + { key: '1', label: 'label.democratic', defaultMessage: 'Democratic View' }, + { key: '2', label: 'label.filmstrip', defaultMessage: 'Filmstrip View' }, + { key: 'space', label: 'me.mutedPTT', defaultMessage: 'Push SPACE to talk' }, + { key: 'a', label: 'label.advanced', defaultMessage: 'Show advanced information' }, + { key: `${String.fromCharCode(8592)} ${String.fromCharCode(8594)}`, label: 'room.browsePeersSpotlight', defaultMessage: 'Browse participants into Spotlight' } +]; +const styles = (theme) => + ({ + dialogPaper : + { + width : '30vw', + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + }, + display : 'flex', + flexDirection : 'column' + }, + paper : { + padding : theme.spacing(1), + textAlign : 'center', + color : theme.palette.text.secondary, + whiteSpace : 'nowrap', + marginRight : theme.spacing(3), + marginBottom : theme.spacing(1), + minWidth : theme.spacing(8) + }, + shortcuts : { + display : 'flex', + flexDirection : 'row', + alignItems : 'center', + paddingLeft : theme.spacing(2), + paddingRight : theme.spacing(2) + }, + tabsHeader : + { + flexGrow : 1, + marginBottom : theme.spacing(1) + } + }); + +const Help = ({ + helpOpen, + handleCloseHelp, + classes +}) => +{ + const intl = useIntl(); + + return ( + { handleCloseHelp(false); }} + classes={{ + paper : classes.dialogPaper + }} + > + + + + + + + {shortcuts.map((value, index) => + { + return ( +
+ + {value.key} + + +
+ ); + })} + + + +
+ ); +}; + +Help.propTypes = +{ + roomClient : PropTypes.object.isRequired, + helpOpen : PropTypes.bool.isRequired, + handleCloseHelp : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + helpOpen : state.room.helpOpen + }); + +const mapDispatchToProps = { + handleCloseHelp : roomActions.setHelpOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.helpOpen === next.room.helpOpen + ); + } + } +)(withStyles(styles)(Help))); diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index cd1e566..d381e4a 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -1,23 +1,32 @@ -import React from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { lobbyPeersKeySelector, - peersLengthSelector + peersLengthSelector, + raisedHandsSelector, + makePermissionSelector } from '../Selectors'; +import { permissions } from '../../permissions'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; import * as roomActions from '../../actions/roomActions'; import * as toolareaActions from '../../actions/toolareaActions'; import { useIntl, FormattedMessage } from 'react-intl'; +import classnames from 'classnames'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; +import MenuItem from '@material-ui/core/MenuItem'; +import Menu from '@material-ui/core/Menu'; +import Popover from '@material-ui/core/Popover'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Avatar from '@material-ui/core/Avatar'; import Badge from '@material-ui/core/Badge'; +import Paper from '@material-ui/core/Paper'; +import ExtensionIcon from '@material-ui/icons/Extension'; import AccountCircle from '@material-ui/icons/AccountCircle'; import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; @@ -26,11 +35,40 @@ 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 VideoCallIcon from '@material-ui/icons/VideoCall'; import Button from '@material-ui/core/Button'; import Tooltip from '@material-ui/core/Tooltip'; +import MoreIcon from '@material-ui/icons/MoreVert'; +import HelpIcon from '@material-ui/icons/Help'; +import InfoIcon from '@material-ui/icons/Info'; const styles = (theme) => ({ + persistentDrawerOpen : + { + width : 'calc(100% - 30vw)', + marginLeft : '30vw', + [theme.breakpoints.down('lg')] : + { + width : 'calc(100% - 40vw)', + marginLeft : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : 'calc(100% - 50vw)', + marginLeft : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : 'calc(100% - 70vw)', + marginLeft : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : 'calc(100% - 90vw)', + marginLeft : '90vw' + } + }, menuButton : { margin : 0, @@ -47,7 +85,7 @@ const styles = (theme) => }, divider : { - marginLeft : theme.spacing(3), + marginLeft : theme.spacing(3) }, show : { @@ -72,14 +110,34 @@ const styles = (theme) => display : 'block' } }, - actionButtons : - { - display : 'flex' + sectionDesktop : { + display : 'none', + [theme.breakpoints.up('md')] : { + display : 'flex' + } + }, + sectionMobile : { + display : 'flex', + [theme.breakpoints.up('md')] : { + display : 'none' + } }, actionButton : { margin : theme.spacing(1, 0), padding : theme.spacing(0, 1) + }, + disabledButton : + { + margin : theme.spacing(1, 0) + }, + green : + { + color : 'rgba(0, 153, 0, 1)' + }, + moreAction : + { + margin : theme.spacing(0.5, 0, 0.5, 1.5) } }); @@ -118,12 +176,47 @@ const TopBar = (props) => { const intl = useIntl(); + const [ mobileMoreAnchorEl, setMobileMoreAnchorEl ] = useState(null); + const [ anchorEl, setAnchorEl ] = useState(null); + const [ currentMenu, setCurrentMenu ] = useState(null); + + const handleExited = () => + { + setCurrentMenu(null); + }; + + const handleMobileMenuOpen = (event) => + { + setMobileMoreAnchorEl(event.currentTarget); + }; + + const handleMobileMenuClose = () => + { + setMobileMoreAnchorEl(null); + }; + + const handleMenuOpen = (event, menu) => + { + setAnchorEl(event.currentTarget); + setCurrentMenu(menu); + }; + + const handleMenuClose = () => + { + setAnchorEl(null); + + handleMobileMenuClose(); + }; + const { roomClient, room, peersLength, lobbyPeers, permanentTopBar, + drawerOverlayed, + toolAreaOpen, + isMobile, myPicture, loggedIn, loginEnabled, @@ -131,13 +224,22 @@ const TopBar = (props) => fullscreen, onFullscreen, setSettingsOpen, + setExtraVideoOpen, + setHelpOpen, + setAboutOpen, setLockDialogOpen, toggleToolArea, openUsersTab, unread, + canProduceExtraVideo, + canLock, + canPromote, classes } = props; + const isMenuOpen = Boolean(anchorEl); + const isMobileMenuOpen = Boolean(mobileMoreAnchorEl); + const lockTooltip = room.locked ? intl.formatMessage({ id : 'tooltip.unLockRoom', @@ -172,170 +274,240 @@ const TopBar = (props) => }); return ( - - - toggleToolArea()} - > - - - - - { window.config && window.config.logo && Logo } - - { window.config && window.config.title ? window.config.title : 'Multiparty meeting' } - -
-
- { fullscreenEnabled && - - - { fullscreen ? - - : - - } - - - } - + + + toggleToolArea()} > + + + + { window.config.logo && Logo } + + { window.config.title ? window.config.title : 'Multiparty meeting' } + +
+
+ + handleMenuOpen(event, 'moreActions')} + color='inherit' + > + + + + { fullscreenEnabled && + + + { fullscreen ? + + : + + } + + + } + openUsersTab()} > - openUsersTab()} > - - - - - - + + + + + setSettingsOpen(!room.settingsOpen)} > - - - - - - { - if (room.locked) - { - roomClient.unlockRoom(); - } - else - { - roomClient.lockRoom(); - } - }} - > - { room.locked ? - - : - - } - - - { lobbyPeers.length > 0 && - setSettingsOpen(!room.settingsOpen)} + > + + + + + + + { + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } + }} + > + { room.locked ? + + : + + } + + + + { lobbyPeers.length > 0 && + + + setLockDialogOpen(!room.lockDialogOpen)} + > + + + + + + + } + { loginEnabled && + + + { + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { myPicture ? + + : + + } + + + } +
+
+ { lobbyPeers.length > 0 && + - setLockDialogOpen(!room.lockDialogOpen)} - > - + setLockDialogOpen(!room.lockDialogOpen)} > - - - + + + + + - } - { loginEnabled && - - - { - loggedIn ? roomClient.logout() : roomClient.login(); - }} - > - { myPicture ? - - : - - } - - - } + } + + + +
-
- - + + + + { currentMenu === 'moreActions' && + + + { + handleMenuClose(); + setExtraVideoOpen(!room.extraVideoOpen); + }} + > + +

+ +

+
+ + { + handleMenuClose(); + setHelpOpen(!room.helpOpen); + }} + > + +

+ +

+
+ + { + handleMenuClose(); + setAboutOpen(!room.aboutOpen); + }} + > + +

+ +

+
+
+ } +
+ + { loginEnabled && + + { + handleMenuClose(); + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { myPicture ? + + : + + } + { loggedIn ? +

+ +

+ : +

+ +

+ } +
+ } + + { + handleMenuClose(); + + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } + }} + > + { room.locked ? + + : + + } + { room.locked ? +

+ +

+ : +

+ +

+ } +
+ + { + handleMenuClose(); + setSettingsOpen(!room.settingsOpen); + }} + > + +

+ +

+
+ + { + handleMenuClose(); + openUsersTab(); + }} + > + + + +

+ +

+
+ { fullscreenEnabled && + + { + handleMenuClose(); + onFullscreen(); + }} + > + { fullscreen ? + + : + + } +

+ +

+
+ } + handleMenuOpen(event, 'moreActions')} + > + +

+ +

+
+
+ ); }; TopBar.propTypes = { - roomClient : PropTypes.object.isRequired, - room : appPropTypes.Room.isRequired, - peersLength : PropTypes.number, - lobbyPeers : PropTypes.array, - permanentTopBar : PropTypes.bool, - myPicture : PropTypes.string, - loggedIn : PropTypes.bool.isRequired, - loginEnabled : PropTypes.bool.isRequired, - fullscreenEnabled : PropTypes.bool, - fullscreen : PropTypes.bool, - onFullscreen : PropTypes.func.isRequired, - setToolbarsVisible : PropTypes.func.isRequired, - setSettingsOpen : PropTypes.func.isRequired, - setLockDialogOpen : PropTypes.func.isRequired, - toggleToolArea : PropTypes.func.isRequired, - openUsersTab : PropTypes.func.isRequired, - unread : PropTypes.number.isRequired, - classes : PropTypes.object.isRequired, - theme : PropTypes.object.isRequired + roomClient : PropTypes.object.isRequired, + room : appPropTypes.Room.isRequired, + isMobile : PropTypes.bool.isRequired, + peersLength : PropTypes.number, + lobbyPeers : PropTypes.array, + permanentTopBar : PropTypes.bool.isRequired, + drawerOverlayed : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, + myPicture : PropTypes.string, + loggedIn : PropTypes.bool.isRequired, + loginEnabled : PropTypes.bool.isRequired, + fullscreenEnabled : PropTypes.bool, + fullscreen : PropTypes.bool, + onFullscreen : PropTypes.func.isRequired, + setToolbarsVisible : PropTypes.func.isRequired, + setSettingsOpen : PropTypes.func.isRequired, + setExtraVideoOpen : PropTypes.func.isRequired, + setHelpOpen : PropTypes.func.isRequired, + setAboutOpen : PropTypes.func.isRequired, + setLockDialogOpen : PropTypes.func.isRequired, + toggleToolArea : PropTypes.func.isRequired, + openUsersTab : PropTypes.func.isRequired, + unread : PropTypes.number.isRequired, + canProduceExtraVideo : PropTypes.bool.isRequired, + canLock : PropTypes.bool.isRequired, + canPromote : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired, + theme : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - room : state.room, - peersLength : peersLengthSelector(state), - lobbyPeers : lobbyPeersKeySelector(state), - permanentTopBar : state.settings.permanentTopBar, - loggedIn : state.me.loggedIn, - loginEnabled : state.me.loginEnabled, - myPicture : state.me.picture, - unread : state.toolarea.unreadMessages + - state.toolarea.unreadFiles - }); +const makeMapStateToProps = () => +{ + const hasExtraVideoPermission = + makePermissionSelector(permissions.EXTRA_VIDEO); + + const hasLockPermission = + makePermissionSelector(permissions.CHANGE_ROOM_LOCK); + + const hasPromotionPermission = + makePermissionSelector(permissions.PROMOTE_PEER); + + const mapStateToProps = (state) => + ({ + room : state.room, + isMobile : state.me.browser.platform === 'mobile', + peersLength : peersLengthSelector(state), + lobbyPeers : lobbyPeersKeySelector(state), + permanentTopBar : state.settings.permanentTopBar, + drawerOverlayed : state.settings.drawerOverlayed, + toolAreaOpen : state.toolarea.toolAreaOpen, + loggedIn : state.me.loggedIn, + loginEnabled : state.me.loginEnabled, + myPicture : state.me.picture, + unread : state.toolarea.unreadMessages + + state.toolarea.unreadFiles + raisedHandsSelector(state), + canProduceExtraVideo : hasExtraVideoPermission(state), + canLock : hasLockPermission(state), + canPromote : hasPromotionPermission(state) + }); + + return mapStateToProps; +}; const mapDispatchToProps = (dispatch) => ({ @@ -402,11 +839,23 @@ const mapDispatchToProps = (dispatch) => }, setSettingsOpen : (settingsOpen) => { - dispatch(roomActions.setSettingsOpen({ settingsOpen })); + dispatch(roomActions.setSettingsOpen(settingsOpen)); + }, + setExtraVideoOpen : (extraVideoOpen) => + { + dispatch(roomActions.setExtraVideoOpen(extraVideoOpen)); + }, + setHelpOpen : (helpOpen) => + { + dispatch(roomActions.setHelpOpen(helpOpen)); + }, + setAboutOpen : (aboutOpen) => + { + dispatch(roomActions.setAboutOpen(aboutOpen)); }, setLockDialogOpen : (lockDialogOpen) => { - dispatch(roomActions.setLockDialogOpen({ lockDialogOpen })); + dispatch(roomActions.setLockDialogOpen(lockDialogOpen)); }, toggleToolArea : () => { @@ -420,7 +869,7 @@ const mapDispatchToProps = (dispatch) => }); export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, mapDispatchToProps, null, { @@ -431,11 +880,15 @@ export default withRoomContext(connect( prev.peers === next.peers && prev.lobbyPeers === next.lobbyPeers && prev.settings.permanentTopBar === next.settings.permanentTopBar && + prev.settings.drawerOverlayed === next.settings.drawerOverlayed && prev.me.loggedIn === next.me.loggedIn && + prev.me.browser === next.me.browser && prev.me.loginEnabled === next.me.loginEnabled && prev.me.picture === next.me.picture && + prev.me.roles === next.me.roles && prev.toolarea.unreadMessages === next.toolarea.unreadMessages && - prev.toolarea.unreadFiles === next.toolarea.unreadFiles + prev.toolarea.unreadFiles === next.toolarea.unreadFiles && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } } diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index d6af3fe..b2cf648 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../RoomContext'; +import classnames from 'classnames'; import isElectron from 'is-electron'; import * as settingsActions from '../actions/settingsActions'; import PropTypes from 'prop-types'; @@ -82,6 +83,10 @@ const styles = (theme) => green : { color : 'rgba(0, 153, 0, 1)' + }, + red : + { + color : 'rgba(153, 0, 0, 1)' } }); @@ -103,7 +108,7 @@ const DialogTitle = withStyles(styles)((props) => }; }, []); - const { children, classes, myPicture, onLogin, ...other } = props; + const { children, classes, myPicture, onLogin, loggedIn, ...other } = props; const handleTooltipClose = () => { @@ -115,19 +120,27 @@ const DialogTitle = withStyles(styles)((props) => setOpen(true); }; + const loginTooltip = loggedIn ? + intl.formatMessage({ + id : 'tooltip.logout', + defaultMessage : 'Log out' + }) + : + intl.formatMessage({ + id : 'tooltip.login', + defaultMessage : 'Log in' + }); + return ( - { window.config && window.config.logo && Logo } + { window.config.logo && Logo } {children} - { window.config && window.config.loginEnabled && + { window.config.loginEnabled && { myPicture ? : - + } @@ -207,12 +222,13 @@ const JoinDialog = ({ > + onLogin={() => { - loggedIn ? roomClient.logout() : roomClient.login(); + loggedIn ? roomClient.logout(roomId) : roomClient.login(roomId); }} + loggedIn={loggedIn} > - { window.config && window.config.title ? window.config.title : 'Multiparty meeting' } + { window.config.title ? window.config.title : 'Multiparty meeting' }
@@ -269,6 +285,16 @@ const JoinDialog = ({ }} fullWidth /> + {!room.inLobby && room.overRoomLimit && + + + + } @@ -301,12 +327,13 @@ const JoinDialog = ({ /> - : + : { room.signInRequired ? - + : - + + })} + > return ( + diff --git a/app/src/components/MeetingDrawer/Chat/ChatInput.js b/app/src/components/MeetingDrawer/Chat/ChatInput.js index 5be44e9..bb07f98 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatInput.js +++ b/app/src/components/MeetingDrawer/Chat/ChatInput.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import Paper from '@material-ui/core/Paper'; import InputBase from '@material-ui/core/InputBase'; import IconButton from '@material-ui/core/IconButton'; @@ -54,6 +56,7 @@ const ChatInput = (props) => roomClient, displayName, picture, + canChat, classes } = props; @@ -66,6 +69,7 @@ const ChatInput = (props) => defaultMessage : 'Enter chat message...' })} value={message || ''} + disabled={!canChat} onChange={handleChange} onKeyPress={(ev) => { @@ -89,6 +93,7 @@ const ChatInput = (props) => color='primary' className={classes.iconButton} aria-label='Send' + disabled={!canChat} onClick={() => { if (message && message !== '') @@ -112,24 +117,36 @@ ChatInput.propTypes = roomClient : PropTypes.object.isRequired, displayName : PropTypes.string, picture : PropTypes.string, + canChat : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - displayName : state.settings.displayName, - picture : state.me.picture - }); +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.SEND_CHAT); + + const mapStateToProps = (state) => + ({ + displayName : state.settings.displayName, + picture : state.me.picture, + canChat : hasPermission(state) + }); + + return mapStateToProps; +}; export default withRoomContext( connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( + prev.room === next.room && + prev.me.roles === next.me.roles && + prev.peers === next.peers && prev.settings.displayName === next.settings.displayName && prev.me.picture === next.me.picture ); diff --git a/app/src/components/MeetingDrawer/Chat/ChatModerator.js b/app/src/components/MeetingDrawer/Chat/ChatModerator.js new file mode 100644 index 0000000..d7b1cad --- /dev/null +++ b/app/src/components/MeetingDrawer/Chat/ChatModerator.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { withStyles } from '@material-ui/core/styles'; +import { useIntl, FormattedMessage } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + display : 'flex', + padding : theme.spacing(1), + boxShadow : '0 2px 5px 2px rgba(0, 0, 0, 0.2)', + backgroundColor : 'rgba(255, 255, 255, 1)' + }, + listheader : + { + padding : theme.spacing(1), + fontWeight : 'bolder' + }, + actionButton : + { + marginLeft : 'auto' + } + }); + +const ChatModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + isChatModerator, + room, + classes + } = props; + + if (!isChatModerator) + return null; + + return ( +
    +
  • + +
  • + +
+ ); +}; + +ChatModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + isChatModerator : PropTypes.bool, + room : PropTypes.object, + classes : PropTypes.object.isRequired +}; + +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.MODERATE_CHAT); + + const mapStateToProps = (state) => + ({ + isChatModerator : hasPermission(state), + room : state.room + }); + + return mapStateToProps; +}; + +export default withRoomContext(connect( + makeMapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.me === next.me && + prev.peers === next.peers + ); + } + } +)(withStyles(styles)(ChatModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/Chat/Message.js b/app/src/components/MeetingDrawer/Chat/Message.js index a60c245..d1ad32f 100644 --- a/app/src/components/MeetingDrawer/Chat/Message.js +++ b/app/src/components/MeetingDrawer/Chat/Message.js @@ -2,10 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; -import DOMPurify from 'dompurify'; +import DOMPurify from 'dompurify'; import marked from 'marked'; import Paper from '@material-ui/core/Paper'; import Typography from '@material-ui/core/Typography'; +import { useIntl } from 'react-intl'; const linkRenderer = new marked.Renderer(); @@ -13,7 +14,7 @@ linkRenderer.link = (href, title, text) => { title = title ? title : href; text = text ? text : href; - + return `${ text }`; }; @@ -55,6 +56,8 @@ const styles = (theme) => const Message = (props) => { + const intl = useIntl(); + const { self, picture, @@ -88,7 +91,16 @@ const Message = (props) => } ) }} /> - {self ? 'Me' : name} - {time} + + { self ? + intl.formatMessage({ + id : 'room.me', + defaultMessage : 'Me' + }) + : + name + } - {time} +
); diff --git a/app/src/components/MeetingDrawer/Chat/MessageList.js b/app/src/components/MeetingDrawer/Chat/MessageList.js index da6891b..d1c86d2 100644 --- a/app/src/components/MeetingDrawer/Chat/MessageList.js +++ b/app/src/components/MeetingDrawer/Chat/MessageList.js @@ -60,7 +60,7 @@ class MessageList extends React.Component myPicture, classes } = this.props; - + return (
{ this.node = node; }}> { diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 8713134..8af1d8b 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -4,7 +4,10 @@ import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import FileList from './FileList'; +import FileSharingModerator from './FileSharingModerator'; import Paper from '@material-ui/core/Paper'; import Button from '@material-ui/core/Button'; @@ -24,6 +27,10 @@ const styles = (theme) => button : { margin : theme.spacing(1) + }, + shareButtonsWrapper : + { + display : 'flex' } }); @@ -35,12 +42,14 @@ const FileSharing = (props) => { if (event.target.files.length > 0) { - props.roomClient.shareFiles(event.target.files); + await props.roomClient.shareFiles(event.target.files); } }; const { canShareFiles, + browser, + canShare, classes } = props; @@ -55,25 +64,61 @@ const FileSharing = (props) => defaultMessage : 'File sharing not supported' }); + const buttonGalleryDescription = canShareFiles ? + intl.formatMessage({ + id : 'label.shareGalleryFile', + defaultMessage : 'Share image' + }) + : + intl.formatMessage({ + id : 'label.fileSharingUnsupported', + defaultMessage : 'File sharing not supported' + }); + return ( - - - + +
+ (e.target.value = null)} + id='share-files-button' + /> + + + { + (browser.platform === 'mobile') && canShareFiles && canShare && + } +
); @@ -81,19 +126,45 @@ const FileSharing = (props) => FileSharing.propTypes = { roomClient : PropTypes.any.isRequired, + browser : PropTypes.object.isRequired, canShareFiles : PropTypes.bool.isRequired, tabOpen : PropTypes.bool.isRequired, + canShare : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - canShareFiles : state.me.canShareFiles, - tabOpen : state.toolarea.currentToolTab === 'files' + const hasPermission = makePermissionSelector(permissions.SHARE_FILE); + + const mapStateToProps = (state) => + { + return { + canShareFiles : state.me.canShareFiles, + browser : state.me.browser, + tabOpen : state.toolarea.currentToolTab === 'files', + canShare : hasPermission(state) + }; }; + + return mapStateToProps; }; export default withRoomContext(connect( - mapStateToProps + makeMapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.me.browser === next.me.browser && + prev.me.roles === next.me.roles && + prev.me.canShareFiles === next.me.canShareFiles && + prev.peers === next.peers && + prev.toolarea.currentToolTab === next.toolarea.currentToolTab + ); + } + } )(withStyles(styles)(FileSharing))); diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js new file mode 100644 index 0000000..05f35e8 --- /dev/null +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { withStyles } from '@material-ui/core/styles'; +import { useIntl, FormattedMessage } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + display : 'flex', + padding : theme.spacing(1), + boxShadow : '0 2px 5px 2px rgba(0, 0, 0, 0.2)', + backgroundColor : 'rgba(255, 255, 255, 1)' + }, + listheader : + { + padding : theme.spacing(1), + fontWeight : 'bolder' + }, + actionButton : + { + marginLeft : 'auto' + } + }); + +const FileSharingModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + isFileSharingModerator, + room, + classes + } = props; + + if (!isFileSharingModerator) + return null; + + return ( +
    +
  • + +
  • + +
+ ); +}; + +FileSharingModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + isFileSharingModerator : PropTypes.bool, + room : PropTypes.object, + classes : PropTypes.object.isRequired +}; + +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.MODERATE_FILES); + + const mapStateToProps = (state) => + ({ + isFileSharingModerator : hasPermission(state), + room : state.room + }); + + return mapStateToProps; +}; + +export default withRoomContext(connect( + makeMapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.me === next.me && + prev.peers === next.peers + ); + } + } +)(withStyles(styles)(FileSharingModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/MeetingDrawer.js b/app/src/components/MeetingDrawer/MeetingDrawer.js index 0f81cde..4ac0831 100644 --- a/app/src/components/MeetingDrawer/MeetingDrawer.js +++ b/app/src/components/MeetingDrawer/MeetingDrawer.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import { raisedHandsSelector } from '../Selectors'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import * as toolareaActions from '../../actions/toolareaActions'; @@ -51,6 +52,7 @@ const MeetingDrawer = (props) => currentToolTab, unreadMessages, unreadFiles, + raisedHands, closeDrawer, setToolTab, classes, @@ -93,10 +95,14 @@ const MeetingDrawer = (props) => } /> + {intl.formatMessage({ + id : 'label.participants', + defaultMessage : 'Participants' + })} + + } /> @@ -116,16 +122,21 @@ MeetingDrawer.propTypes = setToolTab : PropTypes.func.isRequired, unreadMessages : PropTypes.number.isRequired, unreadFiles : PropTypes.number.isRequired, + raisedHands : PropTypes.number.isRequired, closeDrawer : PropTypes.func.isRequired, classes : PropTypes.object.isRequired, theme : PropTypes.object.isRequired }; -const mapStateToProps = (state) => ({ - currentToolTab : state.toolarea.currentToolTab, - unreadMessages : state.toolarea.unreadMessages, - unreadFiles : state.toolarea.unreadFiles -}); +const mapStateToProps = (state) => +{ + return { + currentToolTab : state.toolarea.currentToolTab, + unreadMessages : state.toolarea.unreadMessages, + unreadFiles : state.toolarea.unreadFiles, + raisedHands : raisedHandsSelector(state) + }; +}; const mapDispatchToProps = { setToolTab : toolareaActions.setToolTab @@ -141,7 +152,8 @@ export default connect( return ( prev.toolarea.currentToolTab === next.toolarea.currentToolTab && prev.toolarea.unreadMessages === next.toolarea.unreadMessages && - prev.toolarea.unreadFiles === next.toolarea.unreadFiles + prev.toolarea.unreadFiles === next.toolarea.unreadFiles && + prev.peers === next.peers ); } } diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index 304cbb9..33873d2 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -1,79 +1,55 @@ import React from 'react'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../../RoomContext'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import * as appPropTypes from '../../appPropTypes'; +import { useIntl } from 'react-intl'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import PanIcon from '@material-ui/icons/PanTool'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; -import HandIcon from '../../../images/icon-hand-white.svg'; const styles = (theme) => ({ root : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', display : 'flex' }, - listPeer : - { - display : 'flex' - }, avatar : { borderRadius : '50%', - height : '2rem' + height : '2rem', + marginTop : theme.spacing(0.5) }, peerInfo : { fontSize : '1rem', - border : 'none', display : 'flex', paddingLeft : theme.spacing(1), flexGrow : 1, alignItems : 'center' }, - indicators : + buttons : { - left : 0, - top : 0, - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center', - transition : 'opacity 0.3s' + padding : theme.spacing(1) }, - icon : + green : { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundPosition : 'center', - backgroundSize : '75%', - backgroundRepeat : 'no-repeat', - backgroundColor : 'rgba(0, 0, 0, 0.5)', - transitionProperty : 'opacity, background-color', - transitionDuration : '0.15s', - width : 'var(--media-control-button-size)', - height : 'var(--media-control-button-size)', - opacity : 0.85, - '&:hover' : - { - opacity : 1 - }, - '&.raise-hand' : - { - backgroundImage : `url(${HandIcon})`, - opacity : 1 - } + color : 'rgba(0, 153, 0, 1)' } }); const ListMe = (props) => { + const intl = useIntl(); + const { + roomClient, me, settings, classes @@ -82,29 +58,49 @@ const ListMe = (props) => const picture = me.picture || EmptyAvatar; return ( -
  • -
    - My avatar +
    + My avatar -
    - {settings.displayName} -
    - -
    - { me.raisedHand && -
    - } -
    +
    + {settings.displayName}
    -
  • + + + { + e.stopPropagation(); + + roomClient.setRaisedHand(!me.raisedHand); + }} + > + + + +
    ); }; ListMe.propTypes = { - me : appPropTypes.Me.isRequired, - settings : PropTypes.object.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.object.isRequired, + me : appPropTypes.Me.isRequired, + settings : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => ({ @@ -112,7 +108,7 @@ const mapStateToProps = (state) => ({ settings : state.settings }); -export default connect( +export default withRoomContext(connect( mapStateToProps, null, null, @@ -125,4 +121,4 @@ export default connect( ); } } -)(withStyles(styles)(ListMe)); +)(withStyles(styles)(ListMe))); diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js new file mode 100644 index 0000000..2fff283 --- /dev/null +++ b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + padding : theme.spacing(1), + display : 'flex' + }, + divider : + { + marginLeft : theme.spacing(2) + } + }); + +const ListModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + room, + classes + } = props; + + return ( +
    + +
    + +
    + +
    + +
    + ); +}; + +ListModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + room : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => ({ + room : state.room +}); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room + ); + } + } +)(withStyles(styles)(ListModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 9f74e65..d1d13e0 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -3,41 +3,43 @@ import { connect } from 'react-redux'; import { makePeerConsumerSelector } from '../../Selectors'; import { withStyles } from '@material-ui/core/styles'; import PropTypes from 'prop-types'; -import classnames from 'classnames'; import * as appPropTypes from '../../appPropTypes'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { green } from '@material-ui/core/colors'; import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import VideocamIcon from '@material-ui/icons/Videocam'; +import VideocamOffIcon from '@material-ui/icons/VideocamOff'; import MicIcon from '@material-ui/icons/Mic'; import MicOffIcon from '@material-ui/icons/MicOff'; +import VolumeUpIcon from '@material-ui/icons/VolumeUp'; +import VolumeOffIcon from '@material-ui/icons/VolumeOff'; import ScreenIcon from '@material-ui/icons/ScreenShare'; import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; +import ExitIcon from '@material-ui/icons/ExitToApp'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; -import HandIcon from '../../../images/icon-hand-white.svg'; +import PanIcon from '@material-ui/icons/PanTool'; +import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'; const styles = (theme) => ({ root : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', display : 'flex' }, - listPeer : - { - display : 'flex' - }, avatar : { borderRadius : '50%', - height : '2rem' + height : '2rem', + marginTop : theme.spacing(0.5) }, peerInfo : { fontSize : '1rem', - border : 'none', display : 'flex', paddingLeft : theme.spacing(1), flexGrow : 1, @@ -45,86 +47,17 @@ const styles = (theme) => }, indicators : { - left : 0, - top : 0, - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center', - transition : 'opacity 0.3s' + display : 'flex', + padding : theme.spacing(1) }, - icon : + buttons : { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundPosition : 'center', - backgroundSize : '75%', - backgroundRepeat : 'no-repeat', - backgroundColor : 'rgba(0, 0, 0, 0.5)', - transitionProperty : 'opacity, background-color', - transitionDuration : '0.15s', - width : 'var(--media-control-button-size)', - height : 'var(--media-control-button-size)', - opacity : 0.85, - '&:hover' : - { - opacity : 1 - }, - '&.on' : - { - opacity : 1 - }, - '&.off' : - { - opacity : 0.2 - }, - '&.raise-hand' : - { - backgroundImage : `url(${HandIcon})` - } + padding : theme.spacing(1) }, - controls : + green : { - float : 'right', - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center' - }, - button : - { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundColor : 'rgba(0, 0, 0, 0.5)', - cursor : 'pointer', - transitionProperty : 'opacity, background-color', - transitionDuration : '0.15s', - width : 'var(--media-control-button-size)', - height : 'var(--media-control-button-size)', - opacity : 0.85, - '&:hover' : - { - opacity : 1 - }, - '&.unsupported' : - { - pointerEvents : 'none' - }, - '&.disabled' : - { - pointerEvents : 'none', - backgroundColor : 'var(--media-control-botton-disabled)' - }, - '&.on' : - { - backgroundColor : 'var(--media-control-botton-on)' - }, - '&.off' : - { - backgroundColor : 'var(--media-control-botton-off)' - } + color : 'rgba(0, 153, 0, 1)', + marginLeft : theme.spacing(2) } }); @@ -134,13 +67,22 @@ const ListPeer = (props) => const { roomClient, + isModerator, + spotlight, peer, micConsumer, + webcamConsumer, screenConsumer, children, classes } = props; + const webcamEnabled = ( + Boolean(webcamConsumer) && + !webcamConsumer.locallyPaused && + !webcamConsumer.remotelyPaused + ); + const micEnabled = ( Boolean(micConsumer) && !micConsumer.locallyPaused && @@ -162,36 +104,54 @@ const ListPeer = (props) =>
    {peer.displayName}
    -
    - { peer.raiseHandState && -
    - } -
    - {children} -
    - { screenConsumer && + { peer.raisedHand && + + { + e.stopPropagation(); + + roomClient.lowerPeerHand(peer.id); + }} + > + + + } + { spotlight && + + + + } + { screenConsumer && spotlight && + - { - 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 ? @@ -199,29 +159,181 @@ const ListPeer = (props) => } - } + + } + { spotlight && + + + { + e.stopPropagation(); + + webcamEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : + roomClient.modifyPeerConsumer(peer.id, 'webcam', false); + }} + > + { webcamEnabled ? + + : + + } + + + } + - { - 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 ? - + : - + } -
    + + { isModerator && + + + { + e.stopPropagation(); + + roomClient.kickPeer(peer.id); + }} + > + + + + } + { isModerator && micConsumer && + + + { + e.stopPropagation(); + + roomClient.mutePeer(peer.id); + }} + > + { !micConsumer.remotelyPaused ? + + : + + } + + + } + { isModerator && webcamConsumer && + + + { + e.stopPropagation(); + + roomClient.stopPeerVideo(peer.id); + }} + > + { !webcamConsumer.remotelyPaused ? + + : + + } + + + } + { isModerator && screenConsumer && + + + { + e.stopPropagation(); + + roomClient.stopPeerScreenSharing(peer.id); + }} + > + { !screenConsumer.remotelyPaused ? + + : + + } + + + } + {children}
    ); }; @@ -230,6 +342,8 @@ ListPeer.propTypes = { roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, + isModerator : PropTypes.bool, + spotlight : PropTypes.bool, peer : appPropTypes.Peer.isRequired, micConsumer : appPropTypes.Consumer, webcamConsumer : appPropTypes.Consumer, diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index 3313d2b..a416a64 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -1,16 +1,19 @@ import React from 'react'; import { connect } from 'react-redux'; import { - passivePeersSelector, - spotlightPeersSelector + participantListSelector, + makePermissionSelector } from '../../Selectors'; -import classNames from 'classnames'; +import { permissions } from '../../../permissions'; +import classnames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import PropTypes from 'prop-types'; +import { Flipper, Flipped } from 'react-flip-toolkit'; import { FormattedMessage } from 'react-intl'; import ListPeer from './ListPeer'; import ListMe from './ListMe'; +import ListModerator from './ListModerator'; import Volume from '../../Containers/Volume'; const styles = (theme) => @@ -30,12 +33,10 @@ const styles = (theme) => }, listheader : { - padding : theme.spacing(1), fontWeight : 'bolder' }, listItem : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'pointer', @@ -76,14 +77,26 @@ class ParticipantList extends React.PureComponent const { roomClient, advancedMode, - passivePeers, + isModerator, + participants, + spotlights, selectedPeerId, - spotlightPeers, classes } = this.props; return (
    { this.node = node; }}> + { isModerator && +
      +
    • + +
    • + +
    + }
    • - { spotlightPeers.map((peerId) => ( -
    • roomClient.setSelectedPeer(peerId)} - > - - - -
    • - ))} -
    -
      -
    • - -
    • - { passivePeers.map((peerId) => ( -
    • roomClient.setSelectedPeer(peerId)} - > - -
    • - ))} + + { participants.map((peer) => ( + +
    • roomClient.setSelectedPeer(peer.id)} + > + { spotlights.includes(peer.id) ? + + + + : + + } +
    • +
      + ))} +
    ); @@ -142,32 +155,41 @@ ParticipantList.propTypes = { roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, - passivePeers : PropTypes.array, + isModerator : PropTypes.bool, + participants : PropTypes.array, + spotlights : PropTypes.array, selectedPeerId : PropTypes.string, - spotlightPeers : PropTypes.array, classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - passivePeers : passivePeersSelector(state), - selectedPeerId : state.room.selectedPeerId, - spotlightPeers : spotlightPeersSelector(state) + const hasPermission = makePermissionSelector(permissions.MODERATE_ROOM); + + const mapStateToProps = (state) => + { + return { + isModerator : hasPermission(state), + participants : participantListSelector(state), + spotlights : state.room.spotlights, + selectedPeerId : state.room.selectedPeerId + }; }; + + return mapStateToProps; }; const ParticipantListContainer = withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.peers === next.peers && - prev.room.spotlights === next.room.spotlights && - prev.room.selectedPeerId === next.room.selectedPeerId + prev.room === next.room && + prev.me.roles === next.me.roles && + prev.peers === next.peers ); } } diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js index 550dda0..e2e774c 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -11,10 +11,9 @@ import Peer from '../Containers/Peer'; import Me from '../Containers/Me'; const RATIO = 1.334; -const PADDING_V = 50; -const PADDING_H = 0; +const PADDING = 60; -const styles = () => +const styles = (theme) => ({ root : { @@ -23,6 +22,7 @@ const styles = () => display : 'flex', flexDirection : 'row', flexWrap : 'wrap', + overflow : 'hidden', justifyContent : 'center', alignItems : 'center', alignContent : 'center' @@ -36,6 +36,14 @@ const styles = () => { paddingTop : 60, transition : 'padding .5s' + }, + buttonControlBar : + { + paddingLeft : 60, + [theme.breakpoints.down('sm')] : + { + paddingLeft : 0 + } } }); @@ -66,9 +74,11 @@ class Democratic extends React.PureComponent return; } - const width = this.peersRef.current.clientWidth - PADDING_H; - const height = this.peersRef.current.clientHeight - - (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING_V : PADDING_H); + const width = + this.peersRef.current.clientWidth - (this.props.buttonControlBar ? PADDING : 0); + const height = + this.peersRef.current.clientHeight - + (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING : 0); let x, y, space; @@ -130,6 +140,7 @@ class Democratic extends React.PureComponent spotlightsPeers, toolbarsVisible, permanentTopBar, + buttonControlBar, classes } = this.props; @@ -143,7 +154,9 @@ class Democratic extends React.PureComponent
    @@ -175,17 +188,21 @@ Democratic.propTypes = boxes : PropTypes.number, spotlightsPeers : PropTypes.array.isRequired, toolbarsVisible : PropTypes.bool.isRequired, - permanentTopBar : PropTypes.bool, + permanentTopBar : PropTypes.bool.isRequired, + buttonControlBar : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - boxes : videoBoxesSelector(state), - spotlightsPeers : spotlightPeersSelector(state), - toolbarsVisible : state.room.toolbarsVisible, - permanentTopBar : state.settings.permanentTopBar + boxes : videoBoxesSelector(state), + spotlightsPeers : spotlightPeersSelector(state), + toolbarsVisible : state.room.toolbarsVisible, + permanentTopBar : state.settings.permanentTopBar, + buttonControlBar : state.settings.buttonControlBar, + toolAreaOpen : state.toolarea.toolAreaOpen }; }; @@ -202,8 +219,10 @@ export default connect( prev.consumers === next.consumers && prev.room.spotlights === next.room.spotlights && prev.room.toolbarsVisible === next.room.toolbarsVisible && - prev.settings.permanentTopBar === next.settings.permanentTopBar + prev.settings.permanentTopBar === next.settings.permanentTopBar && + prev.settings.buttonControlBar === next.settings.buttonControlBar && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } } -)(withStyles(styles)(Democratic)); +)(withStyles(styles, { withTheme: true })(Democratic)); diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index 503948e..eb7fd61 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -12,6 +12,12 @@ import Peer from '../Containers/Peer'; import SpeakerPeer from '../Containers/SpeakerPeer'; import Grid from '@material-ui/core/Grid'; +const RATIO = 1.334; +const PADDING_V = 40; +const PADDING_H = 0; +const FILMSTRING_PADDING_V = 10; +const FILMSTRING_PADDING_H = 0; + const styles = () => ({ root : @@ -19,25 +25,24 @@ const styles = () => height : '100%', width : '100%', display : 'grid', + overflow : 'hidden', gridTemplateColumns : '1fr', - gridTemplateRows : '1.6fr minmax(0, 0.4fr)' + gridTemplateRows : '1fr 0.25fr' }, speaker : { - gridArea : '1 / 1 / 2 / 2', + gridArea : '1 / 1 / 1 / 1', display : 'flex', justifyContent : 'center', - alignItems : 'center', - paddingTop : 40 + alignItems : 'center' }, filmStrip : { - gridArea : '2 / 1 / 3 / 2' + gridArea : '2 / 1 / 2 / 1' }, filmItem : { display : 'flex', - marginLeft : '6px', border : 'var(--peer-border)', '&.selected' : { @@ -45,8 +50,18 @@ const styles = () => }, '&.active' : { - opacity : '0.6' + borderColor : 'var(--selected-peer-border-color)' } + }, + hiddenToolBar : + { + paddingTop : 0, + transition : 'padding .5s' + }, + showingToolBar : + { + paddingTop : 60, + transition : 'padding .5s' } }); @@ -58,6 +73,8 @@ class Filmstrip extends React.PureComponent this.resizeTimeout = null; + this.rootContainer = React.createRef(); + this.activePeerContainer = React.createRef(); this.filmStripContainer = React.createRef(); @@ -105,24 +122,38 @@ class Filmstrip extends React.PureComponent { const newState = {}; + const root = this.rootContainer.current; + + if (!root) + return; + + const availableWidth = root.clientWidth; + // Grid is: + // 4/5 speaker + // 1/5 filmstrip + const availableSpeakerHeight = (root.clientHeight * 0.8) - + (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING_V : PADDING_H); + + const availableFilmstripHeight = root.clientHeight * 0.2; + const speaker = this.activePeerContainer.current; if (speaker) { - let speakerWidth = (speaker.clientWidth - 100); + let speakerWidth = (availableWidth - PADDING_H); + + let speakerHeight = speakerWidth / RATIO; - let speakerHeight = (speakerWidth / 4) * 3; - if (this.isSharingCamera(this.getActivePeerId())) { speakerWidth /= 2; - speakerHeight = (speakerWidth / 4) * 3; + speakerHeight = speakerWidth / RATIO; } - - if (speakerHeight > (speaker.clientHeight - 60)) + + if (speakerHeight > (availableSpeakerHeight - PADDING_V)) { - speakerHeight = (speaker.clientHeight - 60); - speakerWidth = (speakerHeight / 3) * 4; + speakerHeight = (availableSpeakerHeight - PADDING_V); + speakerWidth = speakerHeight * RATIO; } newState.speakerWidth = speakerWidth; @@ -133,14 +164,18 @@ class Filmstrip extends React.PureComponent if (filmStrip) { - let filmStripHeight = filmStrip.clientHeight - 10; + let filmStripHeight = availableFilmstripHeight - FILMSTRING_PADDING_V; - let filmStripWidth = (filmStripHeight / 3) * 4; - - if (filmStripWidth * this.props.boxes > (filmStrip.clientWidth - 50)) + let filmStripWidth = filmStripHeight * RATIO; + + if ( + (filmStripWidth * this.props.boxes) > + (availableWidth - FILMSTRING_PADDING_H) + ) { - filmStripWidth = (filmStrip.clientWidth - 50) / this.props.boxes; - filmStripHeight = (filmStripWidth / 4) * 3; + filmStripWidth = (availableWidth - FILMSTRING_PADDING_H) / + this.props.boxes; + filmStripHeight = filmStripWidth / RATIO; } newState.filmStripWidth = filmStripWidth; @@ -172,27 +207,21 @@ class Filmstrip extends React.PureComponent window.removeEventListener('resize', this.updateDimensions); } - componentWillUpdate(nextProps) - { - if (nextProps !== this.props) - { - if ( - nextProps.activeSpeakerId != null && - nextProps.activeSpeakerId !== this.props.myId - ) - { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - lastSpeaker : nextProps.activeSpeakerId - }); - } - } - } - componentDidUpdate(prevProps) { if (prevProps !== this.props) { + if ( + this.props.activeSpeakerId != null && + this.props.activeSpeakerId !== this.props.myId + ) + { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + lastSpeaker : this.props.activeSpeakerId + }); + } + this.updateDimensions(); } } @@ -205,6 +234,8 @@ class Filmstrip extends React.PureComponent myId, advancedMode, spotlights, + toolbarsVisible, + permanentTopBar, classes } = this.props; @@ -223,7 +254,14 @@ class Filmstrip extends React.PureComponent }; return ( -
    +
    { peers[activePeerId] &&
    @@ -268,7 +306,7 @@ class Filmstrip extends React.PureComponent advancedMode={advancedMode} id={peerId} style={peerStyle} - smallButtons + smallContainer />
    @@ -287,28 +325,34 @@ class Filmstrip extends React.PureComponent } Filmstrip.propTypes = { - roomClient : PropTypes.any.isRequired, - activeSpeakerId : PropTypes.string, - advancedMode : PropTypes.bool, - peers : PropTypes.object.isRequired, - consumers : PropTypes.object.isRequired, - myId : PropTypes.string.isRequired, - selectedPeerId : PropTypes.string, - spotlights : PropTypes.array.isRequired, - boxes : PropTypes.number, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + activeSpeakerId : PropTypes.string, + advancedMode : PropTypes.bool, + peers : PropTypes.object.isRequired, + consumers : PropTypes.object.isRequired, + myId : PropTypes.string.isRequired, + selectedPeerId : PropTypes.string, + spotlights : PropTypes.array.isRequired, + boxes : PropTypes.number, + toolbarsVisible : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, + permanentTopBar : PropTypes.bool, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - activeSpeakerId : state.room.activeSpeakerId, - selectedPeerId : state.room.selectedPeerId, - peers : state.peers, - consumers : state.consumers, - myId : state.me.id, - spotlights : state.room.spotlights, - boxes : videoBoxesSelector(state) + activeSpeakerId : state.room.activeSpeakerId, + selectedPeerId : state.room.selectedPeerId, + peers : state.peers, + consumers : state.consumers, + myId : state.me.id, + spotlights : state.room.spotlights, + boxes : videoBoxesSelector(state), + toolbarsVisible : state.room.toolbarsVisible, + toolAreaOpen : state.toolarea.toolAreaOpen, + permanentTopBar : state.settings.permanentTopBar }; }; @@ -322,6 +366,9 @@ export default withRoomContext(connect( return ( prev.room.activeSpeakerId === next.room.activeSpeakerId && prev.room.selectedPeerId === next.room.selectedPeerId && + prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen && + prev.settings.permanentTopBar === next.settings.permanentTopBar && prev.peers === next.peers && prev.consumers === next.consumers && prev.room.spotlights === next.room.spotlights && diff --git a/app/src/components/Notifications/Notifications.js b/app/src/components/Notifications/Notifications.js index ce2fcee..d5a21c7 100644 --- a/app/src/components/Notifications/Notifications.js +++ b/app/src/components/Notifications/Notifications.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import { withSnackbar } from 'notistack'; import * as notificationActions from '../../actions/notificationActions'; +const notificationPosition = window.config.notificationPosition || 'right'; + class Notifications extends Component { displayed = []; @@ -45,7 +47,7 @@ class Notifications extends Component autoHideDuration : notification.timeout, anchorOrigin : { vertical : 'bottom', - horizontal : 'left' + horizontal : notificationPosition } } ); diff --git a/app/src/components/PeerAudio/AudioPeers.js b/app/src/components/PeerAudio/AudioPeers.js index 671f4a2..6e9921e 100644 --- a/app/src/components/PeerAudio/AudioPeers.js +++ b/app/src/components/PeerAudio/AudioPeers.js @@ -1,13 +1,14 @@ import React from 'react'; import { connect } from 'react-redux'; -import { micConsumerSelector } from '../Selectors'; +import { passiveMicConsumerSelector } from '../Selectors'; import PropTypes from 'prop-types'; import PeerAudio from './PeerAudio'; const AudioPeers = (props) => { const { - micConsumers + micConsumers, + audioOutputDevice } = props; return ( @@ -19,6 +20,7 @@ const AudioPeers = (props) => ); }) @@ -29,12 +31,14 @@ const AudioPeers = (props) => AudioPeers.propTypes = { - micConsumers : PropTypes.array + micConsumers : PropTypes.array, + audioOutputDevice : PropTypes.string }; const mapStateToProps = (state) => ({ - micConsumers : micConsumerSelector(state) + micConsumers : passiveMicConsumerSelector(state), + audioOutputDevice : state.settings.selectedAudioOutputDevice }); const AudioPeersContainer = connect( @@ -45,7 +49,10 @@ const AudioPeersContainer = connect( areStatesEqual : (next, prev) => { return ( - prev.consumers === next.consumers + prev.consumers === next.consumers && + prev.room.spotlights === next.room.spotlights && + prev.settings.selectedAudioOutputDevice === + next.settings.selectedAudioOutputDevice ); } } diff --git a/app/src/components/PeerAudio/PeerAudio.js b/app/src/components/PeerAudio/PeerAudio.js index 38d7faf..86790da 100644 --- a/app/src/components/PeerAudio/PeerAudio.js +++ b/app/src/components/PeerAudio/PeerAudio.js @@ -10,6 +10,7 @@ export default class PeerAudio extends React.PureComponent // Latest received audio track. // @type {MediaStreamTrack} this._audioTrack = null; + this._audioOutputDevice = null; } render() @@ -24,17 +25,21 @@ export default class PeerAudio extends React.PureComponent componentDidMount() { - const { audioTrack } = this.props; + const { audioTrack, audioOutputDevice } = this.props; this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); } - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) + componentDidUpdate(prevProps) { - const { audioTrack } = nextProps; + if (prevProps !== this.props) + { + const { audioTrack, audioOutputDevice } = this.props; - this._setTrack(audioTrack); + this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); + } } _setTrack(audioTrack) @@ -60,9 +65,23 @@ export default class PeerAudio extends React.PureComponent audio.srcObject = null; } } + + _setOutputDevice(audioOutputDevice) + { + if (this._audioOutputDevice === audioOutputDevice) + return; + + this._audioOutputDevice = audioOutputDevice; + + const { audio } = this.refs; + + if (audioOutputDevice && typeof audio.setSinkId === 'function') + audio.setSinkId(audioOutputDevice); + } } PeerAudio.propTypes = { - audioTrack : PropTypes.any + audioTrack : PropTypes.any, + audioOutputDevice : PropTypes.string }; diff --git a/app/src/components/Room.js b/app/src/components/Room.js index 59b0665..e862be6 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -12,6 +12,7 @@ import { FormattedMessage } from 'react-intl'; import CookieConsent from 'react-cookie-consent'; import CssBaseline from '@material-ui/core/CssBaseline'; import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'; +import Drawer from '@material-ui/core/Drawer'; import Hidden from '@material-ui/core/Hidden'; import Notifications from './Notifications/Notifications'; import MeetingDrawer from './MeetingDrawer/MeetingDrawer'; @@ -23,8 +24,13 @@ import VideoWindow from './VideoWindow/VideoWindow'; import LockDialog from './AccessControl/LockDialog/LockDialog'; import Settings from './Settings/Settings'; import TopBar from './Controls/TopBar'; +import WakeLock from 'react-wakelock-react16'; +import ExtraVideo from './Controls/ExtraVideo'; +import ButtonControlBar from './Controls/ButtonControlBar'; +import Help from './Controls/Help'; +import About from './Controls/About'; -const TIMEOUT = 5 * 1000; +const TIMEOUT = window.config.hideTimeout || 5000; const styles = (theme) => ({ @@ -40,6 +46,27 @@ const styles = (theme) => backgroundSize : 'cover', backgroundRepeat : 'no-repeat' }, + drawer : + { + width : '30vw', + flexShrink : 0, + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + }, drawerPaper : { width : '30vw', @@ -138,7 +165,11 @@ class Room extends React.PureComponent { const { room, + browser, advancedMode, + showNotifications, + buttonControlBar, + drawerOverlayed, toolAreaOpen, toggleToolArea, classes, @@ -151,19 +182,21 @@ class Room extends React.PureComponent democratic : Democratic }[room.mode]; + const container = window !== undefined ? window.document.body : undefined; + return (
    { !isElectron() && } > @@ -175,7 +208,9 @@ class Room extends React.PureComponent - + { showNotifications && + + } @@ -185,25 +220,55 @@ class Room extends React.PureComponent onFullscreen={this.handleToggleFullscreen} /> - + { (browser.platform === 'mobile' || drawerOverlayed) ? + + : + + } + + { browser.platform === 'mobile' && browser.os !== 'ios' && + + } + { buttonControlBar && + + } + { room.lockDialogOpen && } @@ -211,6 +276,17 @@ class Room extends React.PureComponent { room.settingsOpen && } + + { room.extraVideoOpen && + + } + { room.helpOpen && + + } + { room.aboutOpen && + + } +
    ); } @@ -219,7 +295,11 @@ class Room extends React.PureComponent Room.propTypes = { room : appPropTypes.Room.isRequired, + browser : PropTypes.object.isRequired, advancedMode : PropTypes.bool.isRequired, + showNotifications : PropTypes.bool.isRequired, + buttonControlBar : PropTypes.bool.isRequired, + drawerOverlayed : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired, setToolbarsVisible : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired, @@ -229,9 +309,13 @@ Room.propTypes = const mapStateToProps = (state) => ({ - room : state.room, - advancedMode : state.settings.advancedMode, - toolAreaOpen : state.toolarea.toolAreaOpen + room : state.room, + browser : state.me.browser, + advancedMode : state.settings.advancedMode, + showNotifications : state.settings.showNotifications, + buttonControlBar : state.settings.buttonControlBar, + drawerOverlayed : state.settings.drawerOverlayed, + toolAreaOpen : state.toolarea.toolAreaOpen }); const mapDispatchToProps = (dispatch) => @@ -255,7 +339,11 @@ export default connect( { return ( prev.room === next.room && + prev.me.browser === next.me.browser && prev.settings.advancedMode === next.settings.advancedMode && + prev.settings.showNotifications === next.settings.showNotifications && + prev.settings.buttonControlBar === next.settings.buttonControlBar && + prev.settings.drawerOverlayed === next.settings.drawerOverlayed && prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index 7a9cbfc..d6f4b6e 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -1,5 +1,8 @@ import { createSelector } from 'reselect'; +const meRolesSelect = (state) => state.me.roles; +const roomPermissionsSelect = (state) => state.room.roomPermissions; +const roomAllowWhenRoleMissing = (state) => state.room.allowWhenRoleMissing; const producersSelect = (state) => state.producers; const consumersSelect = (state) => state.consumers; const spotlightsSelector = (state) => state.room.spotlights; @@ -13,6 +16,11 @@ const peersKeySelector = createSelector( (peers) => Object.keys(peers) ); +export const peersValueSelector = createSelector( + peersSelector, + (peers) => Object.values(peers) +); + export const lobbyPeersKeySelector = createSelector( lobbyPeersSelector, (lobbyPeers) => Object.keys(lobbyPeers) @@ -33,6 +41,11 @@ export const screenProducersSelector = createSelector( (producers) => Object.values(producers).filter((producer) => producer.source === 'screen') ); +export const extraVideoProducersSelector = createSelector( + producersSelect, + (producers) => Object.values(producers).filter((producer) => producer.source === 'extravideo') +); + export const micProducerSelector = createSelector( producersSelect, (producers) => Object.values(producers).find((producer) => producer.source === 'mic') @@ -63,6 +76,33 @@ export const screenConsumerSelector = createSelector( (consumers) => Object.values(consumers).filter((consumer) => consumer.source === 'screen') ); +export const spotlightScreenConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'screen' && spotlights.includes(consumer.peerId) + ) +); + +export const spotlightExtraVideoConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'extravideo' && spotlights.includes(consumer.peerId) + ) +); + +export const passiveMicConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'mic' && !spotlights.includes(consumer.peerId) + ) +); + export const spotlightsLengthSelector = createSelector( spotlightsSelector, (spotlights) => spotlights.length @@ -74,35 +114,83 @@ export const spotlightPeersSelector = createSelector( (spotlights, peers) => peers.filter((peerId) => spotlights.includes(peerId)) ); +export const spotlightSortedPeersSelector = createSelector( + spotlightsSelector, + peersValueSelector, + (spotlights, peers) => + peers.filter((peer) => spotlights.includes(peer.id) && !peer.raisedHand) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +const raisedHandSortedPeers = createSelector( + peersValueSelector, + (peers) => peers.filter((peer) => peer.raisedHand) + .sort((a, b) => a.raisedHandTimestamp - b.raisedHandTimestamp) +); + +const peersSortedSelector = createSelector( + spotlightsSelector, + peersValueSelector, + (spotlights, peers) => + peers.filter((peer) => !spotlights.includes(peer.id) && !peer.raisedHand) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +export const participantListSelector = createSelector( + raisedHandSortedPeers, + spotlightSortedPeersSelector, + peersSortedSelector, + (raisedHands, spotlights, peers) => + [ ...raisedHands, ...spotlights, ...peers ] +); + export const peersLengthSelector = createSelector( peersSelector, (peers) => Object.values(peers).length ); export const passivePeersSelector = createSelector( - peersKeySelector, + peersValueSelector, spotlightsSelector, - (peers, spotlights) => peers.filter((peerId) => !spotlights.includes(peerId)) + (peers, spotlights) => peers.filter((peer) => !spotlights.includes(peer.id)) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +export const raisedHandsSelector = createSelector( + peersValueSelector, + (peers) => peers.reduce((a, b) => (a + (b.raisedHand ? 1 : 0)), 0) ); export const videoBoxesSelector = createSelector( spotlightsLengthSelector, screenProducersSelector, - screenConsumerSelector, - (spotlightsLength, screenProducers, screenConsumers) => - spotlightsLength + 1 + screenProducers.length + screenConsumers.length + spotlightScreenConsumerSelector, + extraVideoProducersSelector, + spotlightExtraVideoConsumerSelector, + ( + spotlightsLength, + screenProducers, + screenConsumers, + extraVideoProducers, + extraVideoConsumers + ) => + spotlightsLength + 1 + screenProducers.length + + screenConsumers.length + extraVideoProducers.length + + extraVideoConsumers.length ); export const meProducersSelector = createSelector( micProducerSelector, webcamProducerSelector, screenProducerSelector, - (micProducer, webcamProducer, screenProducer) => + extraVideoProducersSelector, + (micProducer, webcamProducer, screenProducer, extraVideoProducers) => { return { micProducer, webcamProducer, - screenProducer + screenProducer, + extraVideoProducers }; } ); @@ -125,8 +213,60 @@ export const makePeerConsumerSelector = () => consumersArray.find((consumer) => consumer.source === 'webcam'); const screenConsumer = consumersArray.find((consumer) => consumer.source === 'screen'); + const extraVideoConsumers = + consumersArray.filter((consumer) => consumer.source === 'extravideo'); - return { micConsumer, webcamConsumer, screenConsumer }; + return { micConsumer, webcamConsumer, screenConsumer, extraVideoConsumers }; } ); }; + +// Very important that the Components that use this +// selector need to check at least these state changes: +// +// areStatesEqual : (next, prev) => +// { +// return ( +// prev.room.roomPermissions === next.room.roomPermissions && +// prev.room.allowWhenRoleMissing === next.room.allowWhenRoleMissing && +// prev.peers === next.peers && +// prev.me.roles === next.me.roles +// ); +// } +export const makePermissionSelector = (permission) => +{ + return createSelector( + meRolesSelect, + roomPermissionsSelect, + roomAllowWhenRoleMissing, + peersValueSelector, + (roles, roomPermissions, allowWhenRoleMissing, peers) => + { + if (!roomPermissions) + return false; + + const permitted = roles.some((role) => + roomPermissions[permission].includes(role) + ); + + if (permitted) + return true; + + if (!allowWhenRoleMissing) + return false; + + // Allow if config is set, and no one is present + if (allowWhenRoleMissing.includes(permission) && + peers.filter( + (peer) => + peer.roles.some( + (role) => roomPermissions[permission].includes(role) + ) + ).length === 0 + ) + return true; + + return false; + } + ); +}; \ No newline at end of file diff --git a/app/src/components/Settings/AdvancedSettings.js b/app/src/components/Settings/AdvancedSettings.js new file mode 100644 index 0000000..b8c1c19 --- /dev/null +++ b/app/src/components/Settings/AdvancedSettings.js @@ -0,0 +1,135 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as settingsActions from '../../actions/settingsActions'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { useIntl, FormattedMessage } from 'react-intl'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Switch from '@material-ui/core/Switch'; + +const styles = (theme) => + ({ + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + }, + switchLabel : { + justifyContent : 'space-between', + flex : 'auto', + display : 'flex', + padding : theme.spacing(1), + marginRight : 0 + } + }); + +const AdvancedSettings = ({ + roomClient, + settings, + onToggleAdvancedMode, + onToggleNotificationSounds, + classes +}) => +{ + const intl = useIntl(); + + return ( + + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.advancedMode', + defaultMessage : 'Advanced mode' + })} + /> + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.notificationSounds', + defaultMessage : 'Notification sounds' + })} + /> + { !window.config.lockLastN && +
    + + + + + + +
    + } +
    + ); +}; + +AdvancedSettings.propTypes = +{ + roomClient : PropTypes.any.isRequired, + settings : PropTypes.object.isRequired, + onToggleAdvancedMode : PropTypes.func.isRequired, + onToggleNotificationSounds : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + settings : state.settings + }); + +const mapDispatchToProps = { + onToggleAdvancedMode : settingsActions.toggleAdvancedMode, + onToggleNotificationSounds : settingsActions.toggleNotificationSounds +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.settings === next.settings + ); + } + } +)(withStyles(styles)(AdvancedSettings))); \ No newline at end of file diff --git a/app/src/components/Settings/AppearenceSettings.js b/app/src/components/Settings/AppearenceSettings.js new file mode 100644 index 0000000..f2463a2 --- /dev/null +++ b/app/src/components/Settings/AppearenceSettings.js @@ -0,0 +1,196 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import * as appPropTypes from '../appPropTypes'; +import { withStyles } from '@material-ui/core/styles'; +import * as roomActions from '../../actions/roomActions'; +import * as settingsActions from '../../actions/settingsActions'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Switch from '@material-ui/core/Switch'; + +const styles = (theme) => + ({ + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + }, + switchLabel : { + justifyContent : 'space-between', + flex : 'auto', + display : 'flex', + padding : theme.spacing(1), + marginRight : 0 + } + }); + +const AppearenceSettings = ({ + isMobile, + room, + settings, + onTogglePermanentTopBar, + onToggleHiddenControls, + onToggleButtonControlBar, + onToggleShowNotifications, + onToggleDrawerOverlayed, + handleChangeMode, + classes +}) => +{ + const intl = useIntl(); + + const modes = [ { + value : 'democratic', + label : intl.formatMessage({ + id : 'label.democratic', + defaultMessage : 'Democratic view' + }) + }, { + value : 'filmstrip', + label : intl.formatMessage({ + id : 'label.filmstrip', + defaultMessage : 'Filmstrip view' + }) + } ]; + + return ( + +
    + + + + + + +
    + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.permanentTopBar', + defaultMessage : 'Permanent top bar' + })} + /> + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.hiddenControls', + defaultMessage : 'Hidden media controls' + })} + /> + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.buttonControlBar', + defaultMessage : 'Separate media controls' + })} + /> + { !isMobile && + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.drawerOverlayed', + defaultMessage : 'Side drawer over content' + })} + /> + } + } + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.showNotifications', + defaultMessage : 'Show notifications' + })} + /> +
    + ); +}; + +AppearenceSettings.propTypes = +{ + isMobile : PropTypes.bool.isRequired, + room : appPropTypes.Room.isRequired, + settings : PropTypes.object.isRequired, + onTogglePermanentTopBar : PropTypes.func.isRequired, + onToggleHiddenControls : PropTypes.func.isRequired, + onToggleButtonControlBar : PropTypes.func.isRequired, + onToggleShowNotifications : PropTypes.func.isRequired, + onToggleDrawerOverlayed : PropTypes.func.isRequired, + handleChangeMode : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + isMobile : state.me.browser.platform === 'mobile', + room : state.room, + settings : state.settings + }); + +const mapDispatchToProps = { + onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, + onToggleHiddenControls : settingsActions.toggleHiddenControls, + onToggleShowNotifications : settingsActions.toggleShowNotifications, + onToggleButtonControlBar : settingsActions.toggleButtonControlBar, + onToggleDrawerOverlayed : settingsActions.toggleDrawerOverlayed, + handleChangeMode : roomActions.setDisplayMode +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me.browser === next.me.browser && + prev.room === next.room && + prev.settings === next.settings + ); + } + } +)(withStyles(styles)(AppearenceSettings)); \ No newline at end of file diff --git a/app/src/components/Settings/MediaSettings.js b/app/src/components/Settings/MediaSettings.js new file mode 100644 index 0000000..6d65ff8 --- /dev/null +++ b/app/src/components/Settings/MediaSettings.js @@ -0,0 +1,578 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import * as appPropTypes from '../appPropTypes'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as settingsActions from '../../actions/settingsActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import classnames from 'classnames'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Slider from '@material-ui/core/Slider'; +import Typography from '@material-ui/core/Typography'; +import Collapse from '@material-ui/core/Collapse'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import Switch from '@material-ui/core/Switch'; + +const NoiseSlider = withStyles( + { + root : + { + color : '#3880ff', + height : 2, + padding : '15px 0' + }, + track : { + height : 2 + }, + rail : { + height : 2, + opacity : 0.2 + }, + mark : { + backgroundColor : '#bfbfbf', + height : 10, + width : 3, + marginTop : -3 + }, + markActive : { + opacity : 1, + backgroundColor : 'currentColor' + } + })(Slider); + +const styles = (theme) => ({ + setting : + { + padding : theme.spacing(2) + }, + margin : + { + height : theme.spacing(3) + }, + root : { + width : '100%', + backgroundColor : theme.palette.background.paper + }, + switchLabel : { + justifyContent : 'space-between', + flex : 'auto', + display : 'flex', + padding : theme.spacing(1) + }, + nested : { + display : 'block', + paddingTop : 0, + paddingBottom : 0, + paddingLeft : '25px', + paddingRight : '25px' + }, + formControl : + { + display : 'flex' + } +}); + +const MediaSettings = ({ + setEchoCancellation, + setAutoGainControl, + setNoiseSuppression, + setVoiceActivatedUnmute, + roomClient, + me, + volume, + settings, + classes +}) => +{ + const intl = useIntl(); + + const [ audioSettingsOpen, setAudioSettingsOpen ] = React.useState(false); + const [ videoSettingsOpen, setVideoSettingsOpen ] = React.useState(false); + + const resolutions = [ { + value : 'low', + label : intl.formatMessage({ + id : 'label.low', + defaultMessage : 'Low' + }) + }, + { + value : 'medium', + label : intl.formatMessage({ + id : 'label.medium', + defaultMessage : 'Medium' + }) + }, + { + value : 'high', + label : intl.formatMessage({ + id : 'label.high', + defaultMessage : 'High (HD)' + }) + }, + { + value : 'veryhigh', + label : intl.formatMessage({ + id : 'label.veryHigh', + defaultMessage : 'Very high (FHD)' + }) + }, + { + value : 'ultra', + label : intl.formatMessage({ + id : 'label.ultra', + defaultMessage : 'Ultra (UHD)' + }) + } ]; + + let webcams; + + if (me.webcamDevices) + webcams = Object.values(me.webcamDevices); + else + webcams = []; + + let audioDevices; + + if (me.audioDevices) + audioDevices = Object.values(me.audioDevices); + else + audioDevices = []; + + let audioOutputDevices; + + if (me.audioOutputDevices) + audioOutputDevices = Object.values(me.audioOutputDevices); + else + audioOutputDevices = []; + + return ( + +
    + + + + { webcams.length > 0 ? + intl.formatMessage({ + id : 'settings.selectCamera', + defaultMessage : 'Select video device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectCamera', + defaultMessage : 'Unable to select video device' + }) + } + + + + setVideoSettingsOpen(!videoSettingsOpen)}> + + {videoSettingsOpen ? : } + + + + + + + + + { /* + + + + + + + + + + + + + */ } + + + + + + + + +
    +
    + + + + { audioDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectAudio', + defaultMessage : 'Select audio input device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectAudio', + defaultMessage : 'Unable to select audio input device' + }) + } + + + { 'audioOutputSupportedBrowsers' in window.config && + window.config.audioOutputSupportedBrowsers.includes(me.browser.name) && + + + + { audioOutputDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectAudioOutput', + defaultMessage : 'Select audio output device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectAudioOutput', + defaultMessage : 'Unable to select audio output device' + }) + } + + + } + + setAudioSettingsOpen(!audioSettingsOpen)}> + + {audioSettingsOpen ? : } + + + + + + { + setEchoCancellation(event.target.checked); + roomClient.updateMic(); + }} + />} + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.echoCancellation', + defaultMessage : 'Echo cancellation' + })} + /> + + + + { + setAutoGainControl(event.target.checked); + roomClient.updateMic(); + }} + />} + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.autoGainControl', + defaultMessage : 'Auto gain control' + })} + /> + + + + { + setNoiseSuppression(event.target.checked); + roomClient.updateMic(); + }} + />} + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.noiseSuppression', + defaultMessage : 'Noise suppression' + })} + /> + + + + { + setVoiceActivatedUnmute(event.target.checked); + }} + />} + labelPlacement='start' + label={intl.formatMessage({ + id : 'settings.voiceActivatedUnmute', + defaultMessage : 'Voice activated unmute' + })} + /> + + +
    + + { + intl.formatMessage({ + id : 'settings.noiseThreshold', + defaultMessage : 'Noise threshold' + }) + }: + + + { + roomClient._setNoiseThreshold(value); + }} + marks={[ { value: volume, label: `${volume} dB` } ]} + /> + + + + + + + ); +}; + +MediaSettings.propTypes = +{ + roomClient : PropTypes.any.isRequired, + setEchoCancellation : PropTypes.func.isRequired, + setAutoGainControl : PropTypes.func.isRequired, + setNoiseSuppression : PropTypes.func.isRequired, + setVoiceActivatedUnmute : PropTypes.func.isRequired, + me : appPropTypes.Me.isRequired, + volume : PropTypes.number, + settings : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => +{ + return { + me : state.me, + volume : state.peerVolumes[state.me.id], + settings : state.settings + }; +}; + +const mapDispatchToProps = { + setEchoCancellation : settingsActions.setEchoCancellation, + setAutoGainControl : settingsActions.setAutoGainControl, + setNoiseSuppression : settingsActions.setNoiseSuppression, + setVoiceActivatedUnmute : settingsActions.setVoiceActivatedUnmute +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me === next.me && + prev.settings === next.settings && + prev.peerVolumes[prev.me.id] === next[next.me.id] + ); + } + } +)(withStyles(styles)(MediaSettings))); \ No newline at end of file diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 4817efe..cbfb8b1 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -1,22 +1,25 @@ import React from 'react'; import { connect } from 'react-redux'; -import * as appPropTypes from '../appPropTypes'; import { withStyles } from '@material-ui/core/styles'; -import { withRoomContext } from '../../RoomContext'; import * as roomActions from '../../actions/roomActions'; -import * as settingsActions from '../../actions/settingsActions'; import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from 'react-intl'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +import MediaSettings from './MediaSettings'; +import AppearenceSettings from './AppearenceSettings'; +import AdvancedSettings from './AdvancedSettings'; import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogActions from '@material-ui/core/DialogActions'; import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import FormControl from '@material-ui/core/FormControl'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Select from '@material-ui/core/Select'; -import Checkbox from '@material-ui/core/Checkbox'; + +const tabs = +[ + 'media', + 'appearence', + 'advanced' +]; const styles = (theme) => ({ @@ -43,99 +46,27 @@ const styles = (theme) => width : '90vw' } }, - setting : + tabsHeader : { - padding : theme.spacing(2) - }, - formControl : - { - display : 'flex' + flexGrow : 1 } }); const Settings = ({ - roomClient, - room, - me, - settings, - onToggleAdvancedMode, - onTogglePermanentTopBar, + currentSettingsTab, + settingsOpen, handleCloseSettings, - handleChangeMode, + setSettingsTab, classes }) => { const intl = useIntl(); - const modes = [ { - value : 'democratic', - label : intl.formatMessage({ - id : 'label.democratic', - defaultMessage : 'Democratic view' - }) - }, { - value : 'filmstrip', - label : intl.formatMessage({ - id : 'label.filmstrip', - defaultMessage : 'Filmstrip view' - }) - } ]; - - const resolutions = [ { - value : 'low', - label : intl.formatMessage({ - id : 'label.low', - defaultMessage : 'Low' - }) - }, - { - value : 'medium', - label : intl.formatMessage({ - id : 'label.medium', - defaultMessage : 'Medium' - }) - }, - { - value : 'high', - label : intl.formatMessage({ - id : 'label.high', - defaultMessage : 'High (HD)' - }) - }, - { - value : 'veryhigh', - label : intl.formatMessage({ - id : 'label.veryHigh', - defaultMessage : 'Very high (FHD)' - }) - }, - { - value : 'ultra', - label : intl.formatMessage({ - id : 'label.ultra', - defaultMessage : 'Ultra (UHD)' - }) - } ]; - - let webcams; - - if (me.webcamDevices) - webcams = Object.values(me.webcamDevices); - else - webcams = []; - - let audioDevices; - - if (me.audioDevices) - audioDevices = Object.values(me.audioDevices); - else - audioDevices = []; - return ( handleCloseSettings({ settingsOpen: false })} + open={settingsOpen} + onClose={() => handleCloseSettings(false)} classes={{ paper : classes.dialogPaper }} @@ -146,201 +77,40 @@ const Settings = ({ defaultMessage='Settings' /> -
    - - - - { webcams.length > 0 ? - intl.formatMessage({ - id : 'settings.selectCamera', - defaultMessage : 'Select video device' - }) - : - intl.formatMessage({ - id : 'settings.cantSelectCamera', - defaultMessage : 'Unable to select video device' - }) - } - - -
    -
    - - - - { audioDevices.length > 0 ? - intl.formatMessage({ - id : 'settings.selectAudio', - defaultMessage : 'Select audio device' - }) - : - intl.formatMessage({ - id : 'settings.cantSelectAudio', - defaultMessage : 'Unable to select audio device' - }) - } - - -
    -
    - - - - - - -
    -
    - - - - - - -
    - } - label={intl.formatMessage({ - id : 'settings.advancedMode', - defaultMessage : 'Advanced mode' - })} - /> - { settings.advancedMode && - -
    - - - - - - -
    - } - label={intl.formatMessage({ - id : 'settings.permanentTopBar', - defaultMessage : 'Permanent top bar' - })} - /> -
    - } + setSettingsTab(tabs[value])} + indicatorColor='primary' + textColor='primary' + variant='fullWidth' + > + + + + + {currentSettingsTab === 'media' && } + {currentSettingsTab === 'appearence' && } + {currentSettingsTab === 'advanced' && } -