diff --git a/README.md b/README.md index 18f3895..3e00c7b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ $ cp server/config.example.js server/config.js * Copy `app/config.example.js` to `app/config.js` : +In addition, the server requires a screen to be installed for the server +to be able to seed shared torrent files. This is because the headless +Electron instance used by WebTorrent expects one. + +See [webtorrent-hybrid](https://github.com/webtorrent/webtorrent-hybrid) for +more information about this. + +* Copy `config.example.js` as `config.js` and customize it for your scenario: + ```bash $ cp app/config.example.js app/config.js ``` diff --git a/app/.eslintrc.js b/app/.eslintrc.js index 70a5569..6ec2ca4 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -24,14 +24,14 @@ module.exports = version: '15' } }, + parser: 'babel-eslint', parserOptions: { - ecmaVersion: 6, + ecmaVersion: 9, sourceType: 'module', ecmaFeatures: { impliedStrict: true, - experimentalObjectRestSpread: true, jsx: true } }, @@ -121,7 +121,6 @@ module.exports = 'no-implicit-globals': 2, 'no-inner-declarations': 2, 'no-invalid-regexp': 2, - 'no-invalid-this': 2, 'no-irregular-whitespace': 2, 'no-lonely-if': 2, 'no-mixed-operators': 2, @@ -173,7 +172,7 @@ module.exports = 'semi': [ 2, 'always' ], 'semi-spacing': 2, 'space-before-blocks': 2, - 'space-before-function-paren': [ 2, 'never' ], + 'space-before-function-paren': [ 2, { anonymous: 'never', named: 'never', 'asyncArrow': 'always'}], 'space-in-parens': [ 2, 'never' ], 'spaced-comment': [ 2, 'always' ], 'strict': 2, diff --git a/app/gulpfile.js b/app/gulpfile.js index a214167..881d945 100644 --- a/app/gulpfile.js +++ b/app/gulpfile.js @@ -79,13 +79,7 @@ function bundle(options) }) .transform('babelify', { - presets : [ 'es2015', 'react' ], - plugins : - [ - 'transform-runtime', - 'transform-object-assign', - 'transform-object-rest-spread' - ] + presets : [ 'env', 'react-app' ] }) .transform(envify( { @@ -132,21 +126,29 @@ function changeHTML(content) gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); +const LINTING_FILES = [ + 'gulpfile.js', + 'lib/**/*.js', + 'lib/**/*.jsx' +]; + gulp.task('lint', () => { - const src = - [ - 'gulpfile.js', - 'lib/**/*.js', - 'lib/**/*.jsx' - ]; - - return gulp.src(src) + return gulp.src(LINTING_FILES) .pipe(plumber()) .pipe(eslint()) .pipe(eslint.format()); }); +gulp.task('lint-fix', function() +{ + return gulp.src(LINTING_FILES) + .pipe(plumber()) + .pipe(eslint({ fix: true })) + .pipe(eslint.format()) + .pipe(gulp.dest((file) => file.base)); +}); + gulp.task('css', () => { return gulp.src('stylus/index.styl') diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js index 93ac56a..094a82c 100644 --- a/app/lib/RoomClient.js +++ b/app/lib/RoomClient.js @@ -1,6 +1,7 @@ import protooClient from 'protoo-client'; import * as mediasoupClient from 'mediasoup-client'; import Logger from './Logger'; +import hark from 'hark'; import ScreenShare from './ScreenShare'; import { getProtooUrl } from './urlFactory'; import * as cookiesManager from './cookiesManager'; @@ -128,13 +129,16 @@ export default class RoomClient login() { - this._dispatch(stateActions.setLoginInProgress(true)); - const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`; this._loginWindow = window.open(url, 'loginWindow'); } + logout() + { + window.location = '/logout'; + } + closeLoginWindow() { this._loginWindow.close(); @@ -174,6 +178,16 @@ export default class RoomClient }); } + changeProfilePicture(picture) + { + logger.debug('changeProfilePicture() [picture: "%s"]', picture); + + this._protoo.send('change-profile-picture', { picture }).catch((error) => + { + logger.error('shareProfilePicure() | failed: %o', error); + }); + } + sendChatMessage(chatMessage) { logger.debug('sendChatMessage() [chatMessage:"%s"]', chatMessage); @@ -191,6 +205,22 @@ export default class RoomClient }); } + sendFile(file) + { + logger.debug('sendFile() [file: %o]', file); + + return this._protoo.send('send-file', { file }) + .catch((error) => + { + logger.error('sendFile() | failed: %o', error); + + this._dispatch(requestActions.notify({ + typ : 'error', + text : 'An error occurred while sharing a file' + })); + }); + } + getChatHistory() { logger.debug('getChatHistory()'); @@ -208,6 +238,22 @@ export default class RoomClient }); } + getFileHistory() + { + logger.debug('getFileHistory()'); + + return this._protoo.send('file-history', {}) + .catch((error) => + { + logger.error('getFileHistory() | failed: %o', error); + + this._dispatch(requestActions.notify({ + type : 'error', + text : 'Could not get file history' + })); + }); + } + muteMic() { logger.debug('muteMic()'); @@ -899,11 +945,6 @@ export default class RoomClient { this._dispatch( stateActions.setMyRaiseHandState(state)); - - this._dispatch(requestActions.notify( - { - text : 'raiseHand state changed' - })); this._dispatch( stateActions.setMyRaiseHandStateInProgress(false)); }) @@ -1051,34 +1092,38 @@ export default class RoomClient break; } - // This means: server wants to change MY displayName + case 'profile-picture-changed': + { + accept(); + + const { peerName, picture } = request.data; + + this._dispatch(stateActions.setPeerPicture(peerName, picture)); + + break; + } + + // This means: server wants to change MY user information case 'auth': { logger.debug('got auth event from server', request.data); accept(); - if (request.data.verified == true) - { - this.changeDisplayName(request.data.name); - this._dispatch(requestActions.notify( - { - text : `Authenticated successfully: ${request.data}` - } - )); - } - else - { - this._dispatch(requestActions.notify( - { - text : `Authentication failed: ${request.data}` - } - )); - } + this.changeDisplayName(request.data.name); + + this.changeProfilePicture(request.data.picture); + this._dispatch(stateActions.setPicture(request.data.picture)); + this._dispatch(stateActions.loggedIn()); + + this._dispatch(requestActions.notify( + { + text : `Authenticated successfully: ${request.data}` + } + )); + this.closeLoginWindow(); - this._dispatch(stateActions.setLoginInProgress(false)); break; - } case 'raisehand-message': @@ -1102,7 +1147,7 @@ export default class RoomClient logger.debug('Got chat from "%s"', peerName); this._dispatch( - stateActions.addResponseMessage(chatMessage)); + stateActions.addResponseMessage({ ...chatMessage, peerName })); break; } @@ -1123,6 +1168,37 @@ export default class RoomClient break; } + case 'file-receive': + { + accept(); + + const payload = request.data.file; + + this._dispatch(stateActions.addFile(payload)); + + this._dispatch(requestActions.notify({ + text : `${payload.name} shared a file` + })); + + break; + } + + case 'file-history-receive': + { + accept(); + + const files = request.data.fileHistory; + + if (files.length > 0) + { + logger.debug('Got files history'); + + this._dispatch(stateActions.addFileHistory(files)); + } + + break; + } + default: { logger.error('unknown protoo method "%s"', request.method); @@ -1260,7 +1336,8 @@ export default class RoomClient this._dispatch(stateActions.removeAllNotifications()); this.getChatHistory(); - + this.getFileHistory(); + this._dispatch(requestActions.notify( { text : 'You are in the room', @@ -1380,7 +1457,33 @@ export default class RoomClient }) .then(() => { + const stream = new MediaStream; + logger.debug('_setMicProducer() succeeded'); + stream.addTrack(producer.track); + if (!stream.getAudioTracks()[0]) + throw new Error('_setMicProducer(): given stream has no audio track'); + producer.hark = hark(stream, { play: false }); + + // eslint-disable-next-line no-unused-vars + producer.hark.on('volume_change', (dBs, threshold) => + { + // The exact formula to convert from dBs (-100..0) to linear (0..1) is: + // Math.pow(10, dBs / 20) + // However it does not produce a visually useful output, so let exagerate + // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to + // minimize component renderings. + let volume = Math.round(Math.pow(10, dBs / 85) * 10); + + if (volume === 1) + volume = 0; + + if (volume !== producer.volume) + { + producer.volume = volume; + this._dispatch(stateActions.setProducerVolume(producer.id, volume)); + } + }); }) .catch((error) => { @@ -1762,10 +1865,11 @@ export default class RoomClient this._dispatch(stateActions.addPeer( { - name : peer.name, - displayName : displayName, - device : peer.appData.device, - consumers : [] + name : peer.name, + displayName : displayName, + device : peer.appData.device, + raiseHandState : peer.appData.raiseHandState, + consumers : [] })); if (notify) @@ -1823,7 +1927,8 @@ export default class RoomClient track : null, codec : codec ? codec.name : null }, - consumer.peer.name)); + consumer.peer.name) + ); consumer.on('close', (originator) => { @@ -1835,6 +1940,43 @@ export default class RoomClient consumer.id, consumer.peer.name)); }); + consumer.on('handled', (originator) => + { + logger.debug( + 'consumer "handled" event [id:%s, originator:%s, consumer:%o]', + consumer.id, originator, consumer); + if (consumer.kind === 'audio') + { + const stream = new MediaStream; + + stream.addTrack(consumer.track); + if (!stream.getAudioTracks()[0]) + throw new Error('consumer.on("handled" | given stream has no audio track'); + + consumer.hark = hark(stream, { play: false }); + + // eslint-disable-next-line no-unused-vars + consumer.hark.on('volume_change', (dBs, threshold) => + { + // The exact formula to convert from dBs (-100..0) to linear (0..1) is: + // Math.pow(10, dBs / 20) + // However it does not produce a visually useful output, so let exagerate + // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to + // minimize component renderings. + let volume = Math.round(Math.pow(10, dBs / 85) * 10); + + if (volume === 1) + volume = 0; + + if (volume !== consumer.volume) + { + consumer.volume = volume; + this._dispatch(stateActions.setConsumerVolume(consumer.id, volume)); + } + }); + } + }); + consumer.on('pause', (originator) => { logger.debug( diff --git a/app/lib/components/Chat/Chat.jsx b/app/lib/components/Chat/Chat.jsx index e678a3d..898bf75 100644 --- a/app/lib/components/Chat/Chat.jsx +++ b/app/lib/components/Chat/Chat.jsx @@ -14,7 +14,8 @@ class Chat extends Component onSendMessage, disabledInput, autofocus, - displayName + displayName, + picture } = this.props; return ( @@ -22,7 +23,7 @@ class Chat extends Component
{ onSendMessage(e, displayName); }} + onSubmit={(e) => { onSendMessage(e, displayName, picture); }} > { return { disabledInput : state.chatbehavior.disabledInput, - displayName : state.me.displayName + displayName : state.me.displayName, + picture : state.me.picture }; }; const mapDispatchToProps = (dispatch) => { return { - onSendMessage : (event, displayName) => + onSendMessage : (event, displayName, picture) => { event.preventDefault(); const userInput = event.target.message.value; @@ -74,7 +77,7 @@ const mapDispatchToProps = (dispatch) => if (userInput) { dispatch(stateActions.addUserMessage(userInput)); - dispatch(requestActions.sendChatMessage(userInput, displayName)); + dispatch(requestActions.sendChatMessage(userInput, displayName, picture)); } event.target.message.value = ''; } diff --git a/app/lib/components/Chat/MessageList.jsx b/app/lib/components/Chat/MessageList.jsx index d0bbf7b..e80e17b 100644 --- a/app/lib/components/Chat/MessageList.jsx +++ b/app/lib/components/Chat/MessageList.jsx @@ -1,14 +1,9 @@ import React, { Component } from 'react'; +import { compose } from 'redux'; import PropTypes from 'prop-types'; import marked from 'marked'; import { connect } from 'react-redux'; - -const scrollToBottom = () => -{ - const messagesDiv = document.getElementById('messages'); - - messagesDiv.scrollTop = messagesDiv.scrollHeight; -}; +import scrollToBottom from './scrollToBottom'; const linkRenderer = new marked.Renderer(); @@ -22,16 +17,6 @@ linkRenderer.link = (href, title, text) => class MessageList extends Component { - componentDidMount() - { - scrollToBottom(); - } - - componentDidUpdate() - { - scrollToBottom(); - } - getTimeString(time) { return `${(time.getHours() < 10 ? '0' : '')}${time.getHours()}:${(time.getMinutes() < 10 ? '0' : '')}${time.getMinutes()}`; @@ -50,20 +35,28 @@ class MessageList extends Component { const messageTime = new Date(message.time); + const picture = (message.sender === 'response' ? + message.picture : this.props.myPicture) || 'resources/images/avatar-empty.jpeg'; + return (
-
- - {message.name} - {this.getTimeString(messageTime)} - + + +
+
+ + + {message.name} - {this.getTimeString(messageTime)} + +
); @@ -76,18 +69,21 @@ class MessageList extends Component MessageList.propTypes = { - chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired + chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired, + myPicture : PropTypes.string }; const mapStateToProps = (state) => { return { - chatmessages : state.chatmessages + chatmessages : state.chatmessages, + myPicture : state.me.picture }; }; -const MessageListContainer = connect( - mapStateToProps +const MessageListContainer = compose( + connect(mapStateToProps), + scrollToBottom() )(MessageList); export default MessageListContainer; diff --git a/app/lib/components/Chat/scrollToBottom.jsx b/app/lib/components/Chat/scrollToBottom.jsx new file mode 100644 index 0000000..2a107a4 --- /dev/null +++ b/app/lib/components/Chat/scrollToBottom.jsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; + +/** + * A higher order component which scrolls the user to the bottom of the + * wrapped component, provided that the user already was at the bottom + * of the wrapped component. Useful for chats and similar use cases. + * @param {number} treshold The required distance from the bottom required. + */ +const scrollToBottom = (treshold = 0) => (WrappedComponent) => +{ + return class AutoScroller extends Component + { + constructor(props) + { + super(props); + + this.ref = React.createRef(); + } + + getSnapshotBeforeUpdate() + { + // Check if the user has scrolled close enough to the bottom for + // us to scroll to the bottom or not. + return this.elem.scrollHeight - this.elem.scrollTop <= + this.elem.clientHeight - treshold; + } + + scrollToBottom = () => + { + // Scroll the user to the bottom of the wrapped element. + this.elem.scrollTop = this.elem.scrollHeight; + }; + + componentDidMount() + { + // eslint-disable-next-line react/no-find-dom-node + this.elem = findDOMNode(this.ref.current); + + this.scrollToBottom(); + } + + componentDidUpdate(prevProps, prevState, atBottom) + { + if (atBottom) + { + this.scrollToBottom(); + } + } + + render() + { + return ( + + ); + } + }; +}; + +export default scrollToBottom; \ No newline at end of file diff --git a/app/lib/components/FileSharing/DragDropSharing.jsx b/app/lib/components/FileSharing/DragDropSharing.jsx new file mode 100644 index 0000000..279989c --- /dev/null +++ b/app/lib/components/FileSharing/DragDropSharing.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import WebTorrent from 'webtorrent'; +import dragDrop from 'drag-drop'; +import { shareFiles } from './index'; + +export const configureDragDrop = () => +{ + if (WebTorrent.WEBRTC_SUPPORT) + { + dragDrop('body', async (files) => await shareFiles(files)); + } +}; + +export const HoldingOverlay = () => ( +
+ Drop files here to share them +
+); \ No newline at end of file diff --git a/app/lib/components/FileSharing/FileEntry.jsx b/app/lib/components/FileSharing/FileEntry.jsx new file mode 100644 index 0000000..e8f17b0 --- /dev/null +++ b/app/lib/components/FileSharing/FileEntry.jsx @@ -0,0 +1,196 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import magnet from 'magnet-uri'; +import WebTorrent from 'webtorrent'; +import * as requestActions from '../../redux/requestActions'; +import { saveAs } from 'file-saver/FileSaver'; +import { client } from './index'; + +const DEFAULT_PICTURE = 'resources/images/avatar-empty.jpeg'; + +class FileEntry extends Component +{ + state = { + active : false, + numPeers : 0, + progress : 0, + files : null + }; + + saveFile = (file) => + { + file.getBlob((err, blob) => + { + if (err) + { + return this.props.notify({ + text : 'An error occurred while saving a file' + }); + } + + saveAs(blob, file.name); + }); + }; + + handleTorrent = (torrent) => + { + // Torrent already done, this can happen if the + // same file was sent multiple times. + if (torrent.progress === 1) + { + this.setState({ + files : torrent.files, + numPeers : torrent.numPeers, + progress : 1, + active : false, + timeout : false + }); + + return; + } + + const onProgress = () => + { + this.setState({ + numPeers : torrent.numPeers, + progress : torrent.progress + }); + }; + + onProgress(); + + setInterval(onProgress, 500); + + torrent.on('done', () => + { + onProgress(); + clearInterval(onProgress); + + this.setState({ + files : torrent.files, + active : false + }); + }); + }; + + handleDownload = () => + { + this.setState({ + active : true + }); + + const magnetURI = this.props.data.file.magnet; + + const existingTorrent = client.get(magnetURI); + + if (existingTorrent) + { + // Never add duplicate torrents, use the existing one instead. + return this.handleTorrent(existingTorrent); + } + + client.add(magnetURI, this.handleTorrent); + + setTimeout(() => + { + if (this.state.active && this.state.numPeers === 0) + { + this.setState({ + timeout : true + }); + } + }, 10 * 1000); + } + + render() + { + return ( +
+ + +
+ {this.props.data.me ? ( +

You shared a file.

+ ) : ( +

{this.props.data.name} shared a file.

+ )} + + {!this.state.active && !this.state.files && ( +
+ {WebTorrent.WEBRTC_SUPPORT ? ( + + + + ) : ( +

+ Your browser does not support downloading files using WebTorrent. +

+ )} + +

{magnet.decode(this.props.data.file.magnet).dn}

+
+ )} + + {this.state.active && this.state.numPeers === 0 && ( + +

+ Locating peers +

+ + {this.state.timeout && ( +

+ If this process takes a long time, there might not be anyone seeding + this torrent. Try asking someone to reupload the file that you want. +

+ )} +
+ )} + + {this.state.active && this.state.numPeers > 0 && ( + + )} + + {this.state.files && ( + +

Torrent finished downloading.

+ + {this.state.files.map((file, i) => ( +
+ this.saveFile(file)}> + + + +

{file.name}

+
+ ))} +
+ )} +
+
+ ); + } +} + +export const FileEntryProps = { + data : PropTypes.shape({ + name : PropTypes.string.isRequired, + picture : PropTypes.string, + file : PropTypes.shape({ + magnet : PropTypes.string.isRequired + }).isRequired, + me : PropTypes.bool + }).isRequired, + notify : PropTypes.func.isRequired +}; + +FileEntry.propTypes = FileEntryProps; + +const mapDispatchToProps = { + notify : requestActions.notify +}; + +export default connect( + undefined, + mapDispatchToProps +)(FileEntry); \ No newline at end of file diff --git a/app/lib/components/FileSharing/SharedFilesList.jsx b/app/lib/components/FileSharing/SharedFilesList.jsx new file mode 100644 index 0000000..9b2b6ce --- /dev/null +++ b/app/lib/components/FileSharing/SharedFilesList.jsx @@ -0,0 +1,47 @@ +import React, { Component } from 'react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import FileEntry, { FileEntryProps } from './FileEntry'; +import scrollToBottom from '../Chat/scrollToBottom'; + +/** + * This component cannot be pure, as we need to use + * refs to scroll to the bottom when new files arrive. + */ +class SharedFilesList extends Component +{ + render() + { + return ( +
+ {this.props.sharing.map((entry, i) => ( + + ))} +
+ ); + } +} + +SharedFilesList.propTypes = { + sharing : PropTypes.arrayOf(FileEntryProps.data).isRequired +}; + +const mapStateToProps = (state) => + ({ + sharing : state.sharing, + + // Included to scroll to the bottom when the user + // actually opens the tab. When the component first + // mounts, the component is not visible and so the + // component has no height which can be used for scrolling. + tabOpen : state.toolarea.currentToolTab === 'files' + }); + +export default compose( + connect(mapStateToProps), + scrollToBottom() +)(SharedFilesList); diff --git a/app/lib/components/FileSharing/index.jsx b/app/lib/components/FileSharing/index.jsx new file mode 100644 index 0000000..fd41051 --- /dev/null +++ b/app/lib/components/FileSharing/index.jsx @@ -0,0 +1,131 @@ +import React, { Component } from 'react'; +import WebTorrent from 'webtorrent'; +import createTorrent from 'create-torrent'; +import randomString from 'random-string'; +import classNames from 'classnames'; +import * as stateActions from '../../redux/stateActions'; +import * as requestActions from '../../redux/requestActions'; +import { store } from '../../store'; +import config from '../../../config'; +import SharedFilesList from './SharedFilesList'; + +export const client = WebTorrent.WEBRTC_SUPPORT && new WebTorrent({ + tracker : { + rtcConfig : { + iceServers : config.turnServers + } + } +}); + +const notifyPeers = (file) => +{ + const { displayName, picture } = store.getState().me; + + store.dispatch(requestActions.sendFile(file, displayName, picture)); +}; + +export const shareFiles = async (files) => +{ + const notification = + { + id : randomString({ length: 6 }).toLowerCase(), + text : 'Creating torrent', + type : 'info' + }; + + store.dispatch(stateActions.addNotification(notification)); + + createTorrent(files, (err, torrent) => + { + if (err) + { + return store.dispatch(requestActions.notify({ + text : 'An error occured while uploading a file' + })); + } + + const existingTorrent = client.get(torrent); + + if (existingTorrent) + { + return notifyPeers({ + magnet : existingTorrent.magnetURI + }); + } + + client.seed(files, (newTorrent) => + { + store.dispatch(stateActions.removeNotification(notification.id)); + + store.dispatch(requestActions.notify({ + text : 'Torrent successfully created' + })); + + notifyPeers({ + magnet : newTorrent.magnetURI + }); + }); + }); +}; + +class FileSharing extends Component +{ + constructor(props) + { + super(props); + + this.fileInput = React.createRef(); + } + + handleFileChange = async (event) => + { + if (event.target.files.length > 0) + { + await shareFiles(event.target.files); + } + }; + + handleClick = () => + { + if (WebTorrent.WEBRTC_SUPPORT) + { + // We want to open the file dialog when we click a button + // instead of actually rendering the input element itself. + this.fileInput.current.click(); + } + }; + + render() + { + const buttonDescription = WebTorrent.WEBRTC_SUPPORT ? + 'Share file' : 'File sharing not supported'; + + return ( +
+
+ + +
+ {buttonDescription} +
+
+ + +
+ ); + } +} + +export default FileSharing; \ No newline at end of file diff --git a/app/lib/components/Filmstrip.jsx b/app/lib/components/Filmstrip.jsx new file mode 100644 index 0000000..5a86b69 --- /dev/null +++ b/app/lib/components/Filmstrip.jsx @@ -0,0 +1,189 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ResizeObserver from 'resize-observer-polyfill'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import * as stateActions from '../redux/stateActions'; +import Peer from './Peer'; + +class Filmstrip extends Component +{ + constructor(props) + { + super(props); + + this.activePeerContainer = React.createRef(); + } + + state = { + lastSpeaker : null, + width : 400 + }; + + // Find the name of the peer which is currently speaking. This is either + // the latest active speaker, or the manually selected peer, or, if no + // person has spoken yet, the first peer in the list of peers. + getActivePeerName = () => + { + if (this.props.selectedPeerName) + { + return this.props.selectedPeerName; + } + + if (this.state.lastSpeaker) + { + return this.state.lastSpeaker; + } + + const peerNames = Object.keys(this.props.peers); + + if (peerNames.length > 0) + { + return peerNames[0]; + } + }; + + isSharingCamera = (peerName) => this.props.peers[peerName] && + this.props.peers[peerName].consumers.some((consumer) => + this.props.consumers[consumer].source === 'screen'); + + getRatio = () => + { + let ratio = 4 / 3; + + if (this.isSharingCamera(this.getActivePeerName())) + { + ratio *= 2; + } + + return ratio; + }; + + updateDimensions = () => + { + const container = this.activePeerContainer.current; + + if (container) + { + const ratio = this.getRatio(); + + let width = container.clientWidth; + + if (width / ratio > container.clientHeight) + { + width = container.clientHeight * ratio; + } + + this.setState({ + width + }); + } + }; + + componentDidMount() + { + window.addEventListener('resize', this.updateDimensions); + const observer = new ResizeObserver(this.updateDimensions); + + observer.observe(this.activePeerContainer.current); + this.updateDimensions(); + } + + componentWillUnmount() + { + window.removeEventListener('resize', this.updateDimensions); + } + + componentDidUpdate(prevProps) + { + if (prevProps !== this.props) + { + this.updateDimensions(); + + if (this.props.activeSpeakerName !== this.props.myName) + { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + lastSpeaker : this.props.activeSpeakerName + }); + } + } + } + + render() + { + const { peers, advancedMode } = this.props; + + const activePeerName = this.getActivePeerName(); + + return ( +
+
+ {peers[activePeerName] && ( +
+ +
+ )} +
+ +
+
+ {Object.keys(peers).map((peerName) => ( +
this.props.setSelectedPeer(peerName)} + className={classnames('film', { + selected : this.props.selectedPeerName === peerName, + active : this.state.lastSpeaker === peerName + })} + > +
+ +
+
+ ))} +
+
+
+ ); + } +} + +Filmstrip.propTypes = { + activeSpeakerName : PropTypes.string, + advancedMode : PropTypes.bool, + peers : PropTypes.object.isRequired, + consumers : PropTypes.object.isRequired, + myName : PropTypes.string.isRequired, + selectedPeerName : PropTypes.string, + setSelectedPeer : PropTypes.func.isRequired +}; + +const mapStateToProps = (state) => ({ + activeSpeakerName : state.room.activeSpeakerName, + selectedPeerName : state.room.selectedPeerName, + peers : state.peers, + consumers : state.consumers, + myName : state.me.name +}); + +const mapDispatchToProps = { + setSelectedPeer : stateActions.setSelectedPeer +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Filmstrip); diff --git a/app/lib/components/FullScreenView.jsx b/app/lib/components/FullScreenView.jsx index 6192eee..d47ffda 100644 --- a/app/lib/components/FullScreenView.jsx +++ b/app/lib/components/FullScreenView.jsx @@ -11,7 +11,8 @@ const FullScreenView = (props) => const { advancedMode, consumer, - toggleConsumerFullscreen + toggleConsumerFullscreen, + toolbarsVisible } = props; if (!consumer) @@ -39,7 +40,9 @@ const FullScreenView = (props) =>
{ e.stopPropagation(); @@ -53,6 +56,7 @@ const FullScreenView = (props) => videoTrack={consumer ? consumer.track : null} videoVisible={consumerVisible} videoProfile={consumerProfile} + toggleFullscreen={() => toggleConsumerFullscreen(consumer)} />
); @@ -62,13 +66,15 @@ FullScreenView.propTypes = { advancedMode : PropTypes.bool, consumer : appPropTypes.Consumer, - toggleConsumerFullscreen : PropTypes.func.isRequired + toggleConsumerFullscreen : PropTypes.func.isRequired, + toolbarsVisible : PropTypes.bool }; const mapStateToProps = (state) => { return { - consumer : state.consumers[state.room.fullScreenConsumer] + consumer : state.consumers[state.room.fullScreenConsumer], + toolbarsVisible : state.room.toolbarsVisible }; }; diff --git a/app/lib/components/FullView.jsx b/app/lib/components/FullView.jsx index 3edc64a..96b1f0f 100644 --- a/app/lib/components/FullView.jsx +++ b/app/lib/components/FullView.jsx @@ -12,6 +12,8 @@ export default class FullView extends React.Component // Latest received video track. // @type {MediaStreamTrack} this._videoTrack = null; + + this.video = React.createRef(); } render() @@ -24,7 +26,7 @@ export default class FullView extends React.Component return (