Fixed and merge from develop to master

master
Håvar Aambø Fosstveit 2018-08-03 10:38:34 +02:00
commit d6c854a8c3
74 changed files with 11710 additions and 7005 deletions

View File

@ -22,6 +22,15 @@ $ cp server/config.example.js server/config.js
* Copy `app/config.example.js` to `app/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 ```bash
$ cp app/config.example.js app/config.js $ cp app/config.example.js app/config.js
``` ```

View File

@ -24,14 +24,14 @@ module.exports =
version: '15' version: '15'
} }
}, },
parser: 'babel-eslint',
parserOptions: parserOptions:
{ {
ecmaVersion: 6, ecmaVersion: 9,
sourceType: 'module', sourceType: 'module',
ecmaFeatures: ecmaFeatures:
{ {
impliedStrict: true, impliedStrict: true,
experimentalObjectRestSpread: true,
jsx: true jsx: true
} }
}, },
@ -121,7 +121,6 @@ module.exports =
'no-implicit-globals': 2, 'no-implicit-globals': 2,
'no-inner-declarations': 2, 'no-inner-declarations': 2,
'no-invalid-regexp': 2, 'no-invalid-regexp': 2,
'no-invalid-this': 2,
'no-irregular-whitespace': 2, 'no-irregular-whitespace': 2,
'no-lonely-if': 2, 'no-lonely-if': 2,
'no-mixed-operators': 2, 'no-mixed-operators': 2,
@ -173,7 +172,7 @@ module.exports =
'semi': [ 2, 'always' ], 'semi': [ 2, 'always' ],
'semi-spacing': 2, 'semi-spacing': 2,
'space-before-blocks': 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' ], 'space-in-parens': [ 2, 'never' ],
'spaced-comment': [ 2, 'always' ], 'spaced-comment': [ 2, 'always' ],
'strict': 2, 'strict': 2,

View File

@ -79,13 +79,7 @@ function bundle(options)
}) })
.transform('babelify', .transform('babelify',
{ {
presets : [ 'es2015', 'react' ], presets : [ 'env', 'react-app' ]
plugins :
[
'transform-runtime',
'transform-object-assign',
'transform-object-rest-spread'
]
}) })
.transform(envify( .transform(envify(
{ {
@ -132,21 +126,29 @@ function changeHTML(content)
gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); gulp.task('clean', () => del(OUTPUT_DIR, { force: true }));
const LINTING_FILES = [
'gulpfile.js',
'lib/**/*.js',
'lib/**/*.jsx'
];
gulp.task('lint', () => gulp.task('lint', () =>
{ {
const src = return gulp.src(LINTING_FILES)
[
'gulpfile.js',
'lib/**/*.js',
'lib/**/*.jsx'
];
return gulp.src(src)
.pipe(plumber()) .pipe(plumber())
.pipe(eslint()) .pipe(eslint())
.pipe(eslint.format()); .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', () => gulp.task('css', () =>
{ {
return gulp.src('stylus/index.styl') return gulp.src('stylus/index.styl')

View File

@ -1,6 +1,7 @@
import protooClient from 'protoo-client'; import protooClient from 'protoo-client';
import * as mediasoupClient from 'mediasoup-client'; import * as mediasoupClient from 'mediasoup-client';
import Logger from './Logger'; import Logger from './Logger';
import hark from 'hark';
import ScreenShare from './ScreenShare'; import ScreenShare from './ScreenShare';
import { getProtooUrl } from './urlFactory'; import { getProtooUrl } from './urlFactory';
import * as cookiesManager from './cookiesManager'; import * as cookiesManager from './cookiesManager';
@ -128,13 +129,16 @@ export default class RoomClient
login() login()
{ {
this._dispatch(stateActions.setLoginInProgress(true));
const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`; const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`;
this._loginWindow = window.open(url, 'loginWindow'); this._loginWindow = window.open(url, 'loginWindow');
} }
logout()
{
window.location = '/logout';
}
closeLoginWindow() closeLoginWindow()
{ {
this._loginWindow.close(); 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) sendChatMessage(chatMessage)
{ {
logger.debug('sendChatMessage() [chatMessage:"%s"]', 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() getChatHistory()
{ {
logger.debug('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() muteMic()
{ {
logger.debug('muteMic()'); logger.debug('muteMic()');
@ -899,11 +945,6 @@ export default class RoomClient
{ {
this._dispatch( this._dispatch(
stateActions.setMyRaiseHandState(state)); stateActions.setMyRaiseHandState(state));
this._dispatch(requestActions.notify(
{
text : 'raiseHand state changed'
}));
this._dispatch( this._dispatch(
stateActions.setMyRaiseHandStateInProgress(false)); stateActions.setMyRaiseHandStateInProgress(false));
}) })
@ -1051,34 +1092,38 @@ export default class RoomClient
break; 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': case 'auth':
{ {
logger.debug('got auth event from server', request.data); logger.debug('got auth event from server', request.data);
accept(); accept();
if (request.data.verified == true) this.changeDisplayName(request.data.name);
{
this.changeDisplayName(request.data.name); this.changeProfilePicture(request.data.picture);
this._dispatch(requestActions.notify( this._dispatch(stateActions.setPicture(request.data.picture));
{ this._dispatch(stateActions.loggedIn());
text : `Authenticated successfully: ${request.data}`
} this._dispatch(requestActions.notify(
)); {
} text : `Authenticated successfully: ${request.data}`
else }
{ ));
this._dispatch(requestActions.notify(
{
text : `Authentication failed: ${request.data}`
}
));
}
this.closeLoginWindow(); this.closeLoginWindow();
this._dispatch(stateActions.setLoginInProgress(false));
break; break;
} }
case 'raisehand-message': case 'raisehand-message':
@ -1102,7 +1147,7 @@ export default class RoomClient
logger.debug('Got chat from "%s"', peerName); logger.debug('Got chat from "%s"', peerName);
this._dispatch( this._dispatch(
stateActions.addResponseMessage(chatMessage)); stateActions.addResponseMessage({ ...chatMessage, peerName }));
break; break;
} }
@ -1123,6 +1168,37 @@ export default class RoomClient
break; 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: default:
{ {
logger.error('unknown protoo method "%s"', request.method); logger.error('unknown protoo method "%s"', request.method);
@ -1260,6 +1336,7 @@ export default class RoomClient
this._dispatch(stateActions.removeAllNotifications()); this._dispatch(stateActions.removeAllNotifications());
this.getChatHistory(); this.getChatHistory();
this.getFileHistory();
this._dispatch(requestActions.notify( this._dispatch(requestActions.notify(
{ {
@ -1380,7 +1457,33 @@ export default class RoomClient
}) })
.then(() => .then(() =>
{ {
const stream = new MediaStream;
logger.debug('_setMicProducer() succeeded'); 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) => .catch((error) =>
{ {
@ -1762,10 +1865,11 @@ export default class RoomClient
this._dispatch(stateActions.addPeer( this._dispatch(stateActions.addPeer(
{ {
name : peer.name, name : peer.name,
displayName : displayName, displayName : displayName,
device : peer.appData.device, device : peer.appData.device,
consumers : [] raiseHandState : peer.appData.raiseHandState,
consumers : []
})); }));
if (notify) if (notify)
@ -1823,7 +1927,8 @@ export default class RoomClient
track : null, track : null,
codec : codec ? codec.name : null codec : codec ? codec.name : null
}, },
consumer.peer.name)); consumer.peer.name)
);
consumer.on('close', (originator) => consumer.on('close', (originator) =>
{ {
@ -1835,6 +1940,43 @@ export default class RoomClient
consumer.id, consumer.peer.name)); 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) => consumer.on('pause', (originator) =>
{ {
logger.debug( logger.debug(

View File

@ -14,7 +14,8 @@ class Chat extends Component
onSendMessage, onSendMessage,
disabledInput, disabledInput,
autofocus, autofocus,
displayName displayName,
picture
} = this.props; } = this.props;
return ( return (
@ -22,7 +23,7 @@ class Chat extends Component
<MessageList /> <MessageList />
<form <form
data-component='Sender' data-component='Sender'
onSubmit={(e) => { onSendMessage(e, displayName); }} onSubmit={(e) => { onSendMessage(e, displayName, picture); }}
> >
<input <input
type='text' type='text'
@ -45,7 +46,8 @@ Chat.propTypes =
onSendMessage : PropTypes.func, onSendMessage : PropTypes.func,
disabledInput : PropTypes.bool, disabledInput : PropTypes.bool,
autofocus : PropTypes.bool, autofocus : PropTypes.bool,
displayName : PropTypes.string displayName : PropTypes.string,
picture : PropTypes.string
}; };
Chat.defaultProps = Chat.defaultProps =
@ -59,14 +61,15 @@ const mapStateToProps = (state) =>
{ {
return { return {
disabledInput : state.chatbehavior.disabledInput, disabledInput : state.chatbehavior.disabledInput,
displayName : state.me.displayName displayName : state.me.displayName,
picture : state.me.picture
}; };
}; };
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = (dispatch) =>
{ {
return { return {
onSendMessage : (event, displayName) => onSendMessage : (event, displayName, picture) =>
{ {
event.preventDefault(); event.preventDefault();
const userInput = event.target.message.value; const userInput = event.target.message.value;
@ -74,7 +77,7 @@ const mapDispatchToProps = (dispatch) =>
if (userInput) if (userInput)
{ {
dispatch(stateActions.addUserMessage(userInput)); dispatch(stateActions.addUserMessage(userInput));
dispatch(requestActions.sendChatMessage(userInput, displayName)); dispatch(requestActions.sendChatMessage(userInput, displayName, picture));
} }
event.target.message.value = ''; event.target.message.value = '';
} }

View File

@ -1,14 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { compose } from 'redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import marked from 'marked'; import marked from 'marked';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import scrollToBottom from './scrollToBottom';
const scrollToBottom = () =>
{
const messagesDiv = document.getElementById('messages');
messagesDiv.scrollTop = messagesDiv.scrollHeight;
};
const linkRenderer = new marked.Renderer(); const linkRenderer = new marked.Renderer();
@ -22,16 +17,6 @@ linkRenderer.link = (href, title, text) =>
class MessageList extends Component class MessageList extends Component
{ {
componentDidMount()
{
scrollToBottom();
}
componentDidUpdate()
{
scrollToBottom();
}
getTimeString(time) getTimeString(time)
{ {
return `${(time.getHours() < 10 ? '0' : '')}${time.getHours()}:${(time.getMinutes() < 10 ? '0' : '')}${time.getMinutes()}`; 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 messageTime = new Date(message.time);
const picture = (message.sender === 'response' ?
message.picture : this.props.myPicture) || 'resources/images/avatar-empty.jpeg';
return ( return (
<div className='message' key={i}> <div className='message' key={i}>
<div className={message.sender}> <div className={message.sender}>
<div <img className='message-avatar' src={picture} />
className='message-text'
// eslint-disable-next-line react/no-danger <div className='message-content'>
dangerouslySetInnerHTML={{ __html : marked.parse( <div
message.text, className='message-text'
{ sanitize: true, renderer: linkRenderer } // eslint-disable-next-line react/no-danger
) }} dangerouslySetInnerHTML={{ __html : marked.parse(
/> message.text,
<span className='message-time'> { sanitize: true, renderer: linkRenderer }
{message.name} - {this.getTimeString(messageTime)} ) }}
</span> />
<span className='message-time'>
{message.name} - {this.getTimeString(messageTime)}
</span>
</div>
</div> </div>
</div> </div>
); );
@ -76,18 +69,21 @@ class MessageList extends Component
MessageList.propTypes = MessageList.propTypes =
{ {
chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired,
myPicture : PropTypes.string
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
{ {
return { return {
chatmessages : state.chatmessages chatmessages : state.chatmessages,
myPicture : state.me.picture
}; };
}; };
const MessageListContainer = connect( const MessageListContainer = compose(
mapStateToProps connect(mapStateToProps),
scrollToBottom()
)(MessageList); )(MessageList);
export default MessageListContainer; export default MessageListContainer;

View File

@ -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 (
<WrappedComponent
ref={this.ref}
{...this.props}
/>
);
}
};
};
export default scrollToBottom;

View File

@ -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 = () => (
<div id='holding-overlay'>
Drop files here to share them
</div>
);

View File

@ -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 (
<div className='file-entry'>
<img className='file-avatar' src={this.props.data.picture || DEFAULT_PICTURE} />
<div className='file-content'>
{this.props.data.me ? (
<p>You shared a file.</p>
) : (
<p>{this.props.data.name} shared a file.</p>
)}
{!this.state.active && !this.state.files && (
<div className='file-info'>
{WebTorrent.WEBRTC_SUPPORT ? (
<span className='button' onClick={this.handleDownload}>
<img src='resources/images/download-icon.svg' />
</span>
) : (
<p>
Your browser does not support downloading files using WebTorrent.
</p>
)}
<p>{magnet.decode(this.props.data.file.magnet).dn}</p>
</div>
)}
{this.state.active && this.state.numPeers === 0 && (
<Fragment>
<p>
Locating peers
</p>
{this.state.timeout && (
<p>
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.
</p>
)}
</Fragment>
)}
{this.state.active && this.state.numPeers > 0 && (
<progress value={this.state.progress} />
)}
{this.state.files && (
<Fragment>
<p>Torrent finished downloading.</p>
{this.state.files.map((file, i) => (
<div className='file-info' key={i}>
<span className='button' onClick={() => this.saveFile(file)}>
<img src='resources/images/save-icon.svg' />
</span>
<p>{file.name}</p>
</div>
))}
</Fragment>
)}
</div>
</div>
);
}
}
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);

View File

@ -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 (
<div className='shared-files'>
{this.props.sharing.map((entry, i) => (
<FileEntry
data={entry}
key={i}
/>
))}
</div>
);
}
}
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);

View File

@ -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 (
<div data-component='FileSharing'>
<div className='sharing-toolbar'>
<input
style={{ display: 'none' }}
ref={this.fileInput}
type='file'
onChange={this.handleFileChange}
multiple
/>
<div
type='button'
onClick={this.handleClick}
className={classNames('share-file', {
disabled : !WebTorrent.WEBRTC_SUPPORT
})}
>
<span>{buttonDescription}</span>
</div>
</div>
<SharedFilesList />
</div>
);
}
}
export default FileSharing;

View File

@ -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 (
<div data-component='Filmstrip'>
<div className='active-peer-container' ref={this.activePeerContainer}>
{peers[activePeerName] && (
<div
className='active-peer'
style={{
width : this.state.width,
height : this.state.width / this.getRatio()
}}
>
<Peer
advancedMode={advancedMode}
name={activePeerName}
/>
</div>
)}
</div>
<div className='filmstrip'>
<div className='filmstrip-content'>
{Object.keys(peers).map((peerName) => (
<div
key={peerName}
onClick={() => this.props.setSelectedPeer(peerName)}
className={classnames('film', {
selected : this.props.selectedPeerName === peerName,
active : this.state.lastSpeaker === peerName
})}
>
<div className='film-content'>
<Peer
advancedMode={advancedMode}
name={peerName}
/>
</div>
</div>
))}
</div>
</div>
</div>
);
}
}
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);

View File

@ -11,7 +11,8 @@ const FullScreenView = (props) =>
const { const {
advancedMode, advancedMode,
consumer, consumer,
toggleConsumerFullscreen toggleConsumerFullscreen,
toolbarsVisible
} = props; } = props;
if (!consumer) if (!consumer)
@ -39,7 +40,9 @@ const FullScreenView = (props) =>
<div className='controls'> <div className='controls'>
<div <div
className={classnames('button', 'fullscreen')} className={classnames('button', 'fullscreen', 'room-controls', {
visible : toolbarsVisible
})}
onClick={(e) => onClick={(e) =>
{ {
e.stopPropagation(); e.stopPropagation();
@ -53,6 +56,7 @@ const FullScreenView = (props) =>
videoTrack={consumer ? consumer.track : null} videoTrack={consumer ? consumer.track : null}
videoVisible={consumerVisible} videoVisible={consumerVisible}
videoProfile={consumerProfile} videoProfile={consumerProfile}
toggleFullscreen={() => toggleConsumerFullscreen(consumer)}
/> />
</div> </div>
); );
@ -62,13 +66,15 @@ FullScreenView.propTypes =
{ {
advancedMode : PropTypes.bool, advancedMode : PropTypes.bool,
consumer : appPropTypes.Consumer, consumer : appPropTypes.Consumer,
toggleConsumerFullscreen : PropTypes.func.isRequired toggleConsumerFullscreen : PropTypes.func.isRequired,
toolbarsVisible : PropTypes.bool
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
{ {
return { return {
consumer : state.consumers[state.room.fullScreenConsumer] consumer : state.consumers[state.room.fullScreenConsumer],
toolbarsVisible : state.room.toolbarsVisible
}; };
}; };

View File

@ -12,6 +12,8 @@ export default class FullView extends React.Component
// Latest received video track. // Latest received video track.
// @type {MediaStreamTrack} // @type {MediaStreamTrack}
this._videoTrack = null; this._videoTrack = null;
this.video = React.createRef();
} }
render() render()
@ -24,7 +26,7 @@ export default class FullView extends React.Component
return ( return (
<div data-component='FullView'> <div data-component='FullView'>
<video <video
ref='video' ref={this.video}
className={classnames({ className={classnames({
hidden : !videoVisible, hidden : !videoVisible,
loading : videoProfile === 'none' loading : videoProfile === 'none'
@ -50,9 +52,9 @@ export default class FullView extends React.Component
this._setTracks(videoTrack); this._setTracks(videoTrack);
} }
componentWillReceiveProps(nextProps) componentDidUpdate()
{ {
const { videoTrack } = nextProps; const { videoTrack } = this.props;
this._setTracks(videoTrack); this._setTracks(videoTrack);
} }
@ -64,15 +66,13 @@ export default class FullView extends React.Component
this._videoTrack = videoTrack; this._videoTrack = videoTrack;
const { video } = this.refs; const video = this.video.current;
if (videoTrack) if (videoTrack)
{ {
const stream = new MediaStream; const stream = new MediaStream;
if (videoTrack) stream.addTrack(videoTrack);
stream.addTrack(videoTrack);
video.srcObject = stream; video.srcObject = stream;
} }
else else
@ -84,7 +84,8 @@ export default class FullView extends React.Component
FullView.propTypes = FullView.propTypes =
{ {
videoTrack : PropTypes.any, videoTrack : PropTypes.any,
videoVisible : PropTypes.bool, videoVisible : PropTypes.bool,
videoProfile : PropTypes.string videoProfile : PropTypes.string,
toggleFullscreen : PropTypes.func.isRequired
}; };

View File

@ -11,6 +11,24 @@ import ScreenView from './ScreenView';
class Me extends React.Component class Me extends React.Component
{ {
state = {
controlsVisible : false
};
handleMouseOver = () =>
{
this.setState({
controlsVisible : true
});
};
handleMouseOut = () =>
{
this.setState({
controlsVisible : false
});
};
constructor(props) constructor(props)
{ {
super(props); super(props);
@ -85,10 +103,15 @@ class Me extends React.Component
data-tip={tip} data-tip={tip}
data-tip-disable={!tip} data-tip-disable={!tip}
data-type='dark' data-type='dark'
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
> >
<div className={classnames('view-container', 'webcam')}> <div className={classnames('view-container', 'webcam')}>
{connected ? {connected ?
<div className='controls'> <div className={classnames('controls', {
visible : this.state.controlsVisible
})}
>
<div <div
className={classnames('button', 'mic', micState, { className={classnames('button', 'mic', micState, {
disabled : me.audioInProgress disabled : me.audioInProgress
@ -117,6 +140,7 @@ class Me extends React.Component
advancedMode={advancedMode} advancedMode={advancedMode}
peer={me} peer={me}
audioTrack={micProducer ? micProducer.track : null} audioTrack={micProducer ? micProducer.track : null}
volume={micProducer ? micProducer.volume : null}
videoTrack={webcamProducer ? webcamProducer.track : null} videoTrack={webcamProducer ? webcamProducer.track : null}
videoVisible={videoVisible} videoVisible={videoVisible}
audioCodec={micProducer ? micProducer.codec : null} audioCodec={micProducer ? micProducer.codec : null}

View File

@ -6,10 +6,15 @@ import * as appPropTypes from './appPropTypes';
import * as stateActions from '../redux/stateActions'; import * as stateActions from '../redux/stateActions';
import { Appear } from './transitions'; import { Appear } from './transitions';
const Notifications = ({ notifications, onClick }) => const Notifications = ({ notifications, onClick, toolAreaOpen }) =>
{ {
return ( return (
<div data-component='Notifications'> <div
data-component='Notifications'
className={classnames({
'toolarea-open' : toolAreaOpen
})}
>
{ {
notifications.map((notification) => notifications.map((notification) =>
{ {
@ -33,14 +38,18 @@ const Notifications = ({ notifications, onClick }) =>
Notifications.propTypes = Notifications.propTypes =
{ {
notifications : PropTypes.arrayOf(appPropTypes.Notification).isRequired, notifications : PropTypes.arrayOf(appPropTypes.Notification).isRequired,
onClick : PropTypes.func.isRequired onClick : PropTypes.func.isRequired,
toolAreaOpen : PropTypes.bool
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
{ {
const { notifications } = state; const { notifications } = state;
return { notifications }; return {
notifications,
toolAreaOpen : state.toolarea.toolAreaOpen
};
}; };
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = (dispatch) =>

View File

@ -0,0 +1,38 @@
import React from 'react';
import { connect } from 'react-redux';
import { Me } from '../appPropTypes';
const ListMe = ({ me }) =>
{
const picture = me.picture || 'resources/images/avatar-empty.jpeg';
return (
<li className='list-item me'>
<div data-component='ListPeer'>
<img className='avatar' src={picture} />
<div className='peer-info'>
{me.displayName}
</div>
<div className='indicators'>
{me.raisedHand && (
<div className='icon raise-hand on' />
)}
</div>
</div>
</li>
);
};
ListMe.propTypes = {
me : Me.isRequired
};
const mapStateToProps = (state) => ({
me : state.me
});
export default connect(
mapStateToProps
)(ListMe);

View File

@ -38,12 +38,29 @@ const ListPeer = (props) =>
!screenConsumer.remotelyPaused !screenConsumer.remotelyPaused
); );
const picture = peer.picture || 'resources/images/avatar-empty.jpeg';
return ( return (
<div data-component='ListPeer'> <div data-component='ListPeer'>
<img className='avatar' /> <img className='avatar' src={picture} />
<div className='peer-info'> <div className='peer-info'>
{peer.displayName} {peer.displayName}
</div> </div>
<div className='indicators'>
{peer.raiseHandState ?
<div className={
classnames(
'icon', 'raise-hand', {
on : peer.raiseHandState,
off : !peer.raiseHandState
}
)
}
/>
:null
}
</div>
<div className='controls'> <div className='controls'>
{ screenConsumer ? { screenConsumer ?
<div <div
@ -67,6 +84,8 @@ const ListPeer = (props) =>
off : !micEnabled, off : !micEnabled,
disabled : peer.peerAudioInProgress disabled : peer.peerAudioInProgress
})} })}
style={{ opacity : micEnabled && micConsumer ? (micConsumer.volume/10)
+ 0.2 :1 }}
onClick={(e) => onClick={(e) =>
{ {
e.stopPropagation(); e.stopPropagation();

View File

@ -1,48 +1,38 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import classNames from 'classnames';
import * as appPropTypes from '../appPropTypes'; import * as appPropTypes from '../appPropTypes';
import * as requestActions from '../../redux/requestActions';
import * as stateActions from '../../redux/stateActions'; import * as stateActions from '../../redux/stateActions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ListPeer from './ListPeer'; import ListPeer from './ListPeer';
import ListMe from './ListMe';
class ParticipantList extends React.Component const ParticipantList = ({ advancedMode, peers, setSelectedPeer, selectedPeerName }) => (
{ <div data-component='ParticipantList'>
constructor(props) <ul className='list'>
{ <ListMe />
super(props);
}
render() {peers.map((peer) => (
{ <li
const { key={peer.name}
advancedMode, className={classNames('list-item', {
peers selected : peer.name === selectedPeerName
} = this.props; })}
onClick={() => setSelectedPeer(peer.name)}
return ( >
<div data-component='ParticipantList'> <ListPeer name={peer.name} advancedMode={advancedMode} />
<ul className='list'> </li>
{ ))}
peers.map((peer) => </ul>
{ </div>
return ( );
<li key={peer.name} className='list-item'>
<ListPeer name={peer.name} advancedMode={advancedMode} />
</li>
);
})
}
</ul>
</div>
);
}
}
ParticipantList.propTypes = ParticipantList.propTypes =
{ {
advancedMode : PropTypes.bool, advancedMode : PropTypes.bool,
peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired,
setSelectedPeer : PropTypes.func.isRequired,
selectedPeerName : PropTypes.string
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
@ -50,26 +40,13 @@ const mapStateToProps = (state) =>
const peersArray = Object.values(state.peers); const peersArray = Object.values(state.peers);
return { return {
peers : peersArray peers : peersArray,
selectedPeerName : state.room.selectedPeerName
}; };
}; };
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = {
{ setSelectedPeer : stateActions.setSelectedPeer
return {
handleChangeWebcam : (device) =>
{
dispatch(requestActions.changeWebcam(device.value));
},
handleChangeAudioDevice : (device) =>
{
dispatch(requestActions.changeAudioDevice(device.value));
},
onToggleAdvancedMode : () =>
{
dispatch(stateActions.toggleAdvancedMode());
}
};
}; };
const ParticipantListContainer = connect( const ParticipantListContainer = connect(

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
@ -8,130 +8,132 @@ import * as stateActions from '../redux/stateActions';
import PeerView from './PeerView'; import PeerView from './PeerView';
import ScreenView from './ScreenView'; import ScreenView from './ScreenView';
const Peer = (props) => class Peer extends Component
{ {
const { state = {
advancedMode, controlsVisible : false
peer, };
micConsumer,
webcamConsumer,
screenConsumer,
onMuteMic,
onUnmuteMic,
onDisableWebcam,
onEnableWebcam,
onDisableScreen,
onEnableScreen,
toggleConsumerFullscreen,
style
} = props;
const micEnabled = ( handleMouseOver = () =>
Boolean(micConsumer) && {
!micConsumer.locallyPaused && this.setState({
!micConsumer.remotelyPaused controlsVisible : true
); });
};
const videoVisible = ( handleMouseOut = () =>
Boolean(webcamConsumer) && {
!webcamConsumer.locallyPaused && this.setState({
!webcamConsumer.remotelyPaused controlsVisible : false
); });
};
const screenVisible = ( render()
Boolean(screenConsumer) && {
!screenConsumer.locallyPaused && const {
!screenConsumer.remotelyPaused advancedMode,
); peer,
micConsumer,
webcamConsumer,
screenConsumer,
onMuteMic,
onUnmuteMic,
onDisableWebcam,
onEnableWebcam,
onDisableScreen,
onEnableScreen,
toggleConsumerFullscreen,
style
} = this.props;
let videoProfile; const micEnabled = (
Boolean(micConsumer) &&
!micConsumer.locallyPaused &&
!micConsumer.remotelyPaused
);
if (webcamConsumer) const videoVisible = (
videoProfile = webcamConsumer.profile; Boolean(webcamConsumer) &&
!webcamConsumer.locallyPaused &&
!webcamConsumer.remotelyPaused
);
let screenProfile; const screenVisible = (
Boolean(screenConsumer) &&
!screenConsumer.locallyPaused &&
!screenConsumer.remotelyPaused
);
if (screenConsumer) let videoProfile;
screenProfile = screenConsumer.profile;
return ( if (webcamConsumer)
<div videoProfile = webcamConsumer.profile;
data-component='Peer'
className={classnames({
screen : screenConsumer
})}
>
{videoVisible && !webcamConsumer.supported ?
<div className='incompatible-video'>
<p>incompatible video</p>
</div>
:null
}
<div className={classnames('view-container', 'webcam')} style={style}> let screenProfile;
<div className='controls'>
if (screenConsumer)
screenProfile = screenConsumer.profile;
return (
<div
data-component='Peer'
className={classnames({
screen : screenConsumer
})}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
>
{videoVisible && !webcamConsumer.supported ?
<div className='incompatible-video'>
<p>incompatible video</p>
</div>
:null
}
<div className={classnames('view-container', 'webcam')} style={style}>
<div className='indicators'>
{peer.raiseHandState ?
<div className={
classnames(
'icon', 'raise-hand', {
on : peer.raiseHandState,
off : !peer.raiseHandState
}
)
}
/>
:null
}
</div>
<div <div
className={classnames('button', 'mic', { className={classnames('controls', {
on : micEnabled, visible : this.state.controlsVisible
off : !micEnabled,
disabled : peer.peerAudioInProgress
})} })}
onClick={(e) => >
{
e.stopPropagation();
micEnabled ? onMuteMic(peer.name) : onUnmuteMic(peer.name);
}}
/>
<div
className={classnames('button', 'webcam', {
on : videoVisible,
off : !videoVisible,
disabled : peer.peerVideoInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
videoVisible ?
onDisableWebcam(peer.name) : onEnableWebcam(peer.name);
}}
/>
<div
className={classnames('button', 'fullscreen')}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerFullscreen(webcamConsumer);
}}
/>
</div>
<PeerView
advancedMode={advancedMode}
peer={peer}
audioTrack={micConsumer ? micConsumer.track : null}
videoTrack={webcamConsumer ? webcamConsumer.track : null}
videoVisible={videoVisible}
videoProfile={videoProfile}
audioCodec={micConsumer ? micConsumer.codec : null}
videoCodec={webcamConsumer ? webcamConsumer.codec : null}
/>
</div>
{screenConsumer ?
<div className={classnames('view-container', 'screen')} style={style}>
<div className='controls'>
<div <div
className={classnames('button', 'screen', { className={classnames('button', 'mic', {
on : screenVisible, on : micEnabled,
off : !screenVisible, off : !micEnabled,
disabled : peer.peerScreenInProgress disabled : peer.peerAudioInProgress
})} })}
onClick={(e) => onClick={(e) =>
{ {
e.stopPropagation(); e.stopPropagation();
screenVisible ? micEnabled ? onMuteMic(peer.name) : onUnmuteMic(peer.name);
onDisableScreen(peer.name) : onEnableScreen(peer.name); }}
/>
<div
className={classnames('button', 'webcam', {
on : videoVisible,
off : !videoVisible,
disabled : peer.peerVideoInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
videoVisible ?
onDisableWebcam(peer.name) : onEnableWebcam(peer.name);
}} }}
/> />
@ -140,23 +142,67 @@ const Peer = (props) =>
onClick={(e) => onClick={(e) =>
{ {
e.stopPropagation(); e.stopPropagation();
toggleConsumerFullscreen(screenConsumer); toggleConsumerFullscreen(webcamConsumer);
}} }}
/> />
</div> </div>
<ScreenView <PeerView
advancedMode={advancedMode} advancedMode={advancedMode}
screenTrack={screenConsumer ? screenConsumer.track : null} peer={peer}
screenVisible={screenVisible} audioTrack={micConsumer ? micConsumer.track : null}
screenProfile={screenProfile} volume={micConsumer ? micConsumer.volume : null}
screenCodec={screenConsumer ? screenConsumer.codec : null} videoTrack={webcamConsumer ? webcamConsumer.track : null}
videoVisible={videoVisible}
videoProfile={videoProfile}
audioCodec={micConsumer ? micConsumer.codec : null}
videoCodec={webcamConsumer ? webcamConsumer.codec : null}
/> />
</div> </div>
:null
} {screenConsumer ?
</div> <div className={classnames('view-container', 'screen')} style={style}>
); <div
}; className={classnames('controls', {
visible : this.state.controlsVisible
})}
>
<div
className={classnames('button', 'screen', {
on : screenVisible,
off : !screenVisible,
disabled : peer.peerScreenInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
screenVisible ?
onDisableScreen(peer.name) : onEnableScreen(peer.name);
}}
/>
<div
className={classnames('button', 'fullscreen')}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerFullscreen(screenConsumer);
}}
/>
</div>
<ScreenView
advancedMode={advancedMode}
screenTrack={screenConsumer ? screenConsumer.track : null}
screenVisible={screenVisible}
screenProfile={screenProfile}
screenCodec={screenConsumer ? screenConsumer.codec : null}
/>
</div>
:null
}
</div>
);
}
}
Peer.propTypes = Peer.propTypes =
{ {

View File

@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import Spinner from 'react-spinner'; import Spinner from 'react-spinner';
import hark from 'hark';
import * as appPropTypes from './appPropTypes'; import * as appPropTypes from './appPropTypes';
import EditableInput from './EditableInput'; import EditableInput from './EditableInput';
@ -27,10 +26,6 @@ export default class PeerView extends React.Component
// @type {MediaStreamTrack} // @type {MediaStreamTrack}
this._videoTrack = null; this._videoTrack = null;
// Hark instance.
// @type {Object}
this._hark = null;
// Periodic timer for showing video resolution. // Periodic timer for showing video resolution.
this._videoResolutionTimer = null; this._videoResolutionTimer = null;
} }
@ -40,6 +35,7 @@ export default class PeerView extends React.Component
const { const {
isMe, isMe,
peer, peer,
volume,
advancedMode, advancedMode,
videoVisible, videoVisible,
videoProfile, videoProfile,
@ -49,7 +45,6 @@ export default class PeerView extends React.Component
} = this.props; } = this.props;
const { const {
volume,
videoWidth, videoWidth,
videoHeight videoHeight
} = this.state; } = this.state;
@ -149,9 +144,6 @@ export default class PeerView extends React.Component
componentWillUnmount() componentWillUnmount()
{ {
if (this._hark)
this._hark.stop();
clearInterval(this._videoResolutionTimer); clearInterval(this._videoResolutionTimer);
} }
@ -160,6 +152,7 @@ export default class PeerView extends React.Component
const { audioTrack, videoTrack } = nextProps; const { audioTrack, videoTrack } = nextProps;
this._setTracks(audioTrack, videoTrack); this._setTracks(audioTrack, videoTrack);
} }
_setTracks(audioTrack, videoTrack) _setTracks(audioTrack, videoTrack)
@ -170,9 +163,6 @@ export default class PeerView extends React.Component
this._audioTrack = audioTrack; this._audioTrack = audioTrack;
this._videoTrack = videoTrack; this._videoTrack = videoTrack;
if (this._hark)
this._hark.stop();
clearInterval(this._videoResolutionTimer); clearInterval(this._videoResolutionTimer);
this._hideVideoResolution(); this._hideVideoResolution();
@ -190,9 +180,6 @@ export default class PeerView extends React.Component
video.srcObject = stream; video.srcObject = stream;
if (audioTrack)
this._runHark(stream);
if (videoTrack) if (videoTrack)
this._showVideoResolution(); this._showVideoResolution();
} }
@ -202,31 +189,6 @@ export default class PeerView extends React.Component
} }
} }
_runHark(stream)
{
if (!stream.getAudioTracks()[0])
throw new Error('_runHark() | given stream has no audio track');
this._hark = hark(stream, { 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 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 !== this.state.volume)
this.setState({ volume: volume });
});
}
_showVideoResolution() _showVideoResolution()
{ {
this._videoResolutionTimer = setInterval(() => this._videoResolutionTimer = setInterval(() =>
@ -259,6 +221,7 @@ PeerView.propTypes =
[ appPropTypes.Me, appPropTypes.Peer ]).isRequired, [ appPropTypes.Me, appPropTypes.Peer ]).isRequired,
advancedMode : PropTypes.bool, advancedMode : PropTypes.bool,
audioTrack : PropTypes.any, audioTrack : PropTypes.any,
volume : PropTypes.number,
videoTrack : PropTypes.any, videoTrack : PropTypes.any,
videoVisible : PropTypes.bool.isRequired, videoVisible : PropTypes.bool.isRequired,
videoProfile : PropTypes.string, videoProfile : PropTypes.string,

View File

@ -5,46 +5,51 @@ import classnames from 'classnames';
import * as appPropTypes from './appPropTypes'; import * as appPropTypes from './appPropTypes';
import { Appear } from './transitions'; import { Appear } from './transitions';
import Peer from './Peer'; import Peer from './Peer';
import ResizeObserver from 'resize-observer-polyfill';
const RATIO = 1.334;
class Peers extends React.Component class Peers extends React.Component
{ {
constructor(props) constructor(props)
{ {
super(props); super(props);
this.state = { this.state = {
peerWidth : 400, peerWidth : 400,
peerHeight : 300, peerHeight : 300
ratio : 1.334
}; };
this.peersRef = React.createRef();
} }
resizeUpdate() updateDimensions = () =>
{ {
this.updateDimensions(); if (!this.peersRef.current)
}
updateDimensions(props = this.props)
{
const n = props.videoStreams ? props.videoStreams : 0;
if (n == 0)
{ {
return; return;
} }
const width = this.refs.peers.clientWidth; const n = this.props.boxes;
const height = this.refs.peers.clientHeight;
if (n === 0)
{
return;
}
const width = this.peersRef.current.clientWidth;
const height = this.peersRef.current.clientHeight;
let x, y, space; let x, y, space;
for (let rows = 1; rows < 100; rows = rows + 1) for (let rows = 1; rows < 100; rows = rows + 1)
{ {
x = width / Math.ceil(n / rows); x = width / Math.ceil(n / rows);
y = x / this.state.ratio; y = x / RATIO;
if (height < (y * rows)) if (height < (y * rows))
{ {
y = height / rows; y = height / rows;
x = this.state.ratio * y; x = RATIO * y;
break; break;
} }
space = height - (y * (rows)); space = height - (y * (rows));
@ -60,21 +65,24 @@ class Peers extends React.Component
peerHeight : 0.9 * y peerHeight : 0.9 * y
}); });
} }
} };
componentDidMount() componentDidMount()
{ {
window.addEventListener('resize', this.resizeUpdate.bind(this)); window.addEventListener('resize', this.updateDimensions);
const observer = new ResizeObserver(this.updateDimensions);
observer.observe(this.peersRef.current);
} }
componentWillUnmount() componentWillUnmount()
{ {
window.removeEventListener('resize', this.resizeUpdate.bind(this)); window.removeEventListener('resize', this.updateDimensions);
} }
componentWillReceiveProps(nextProps) componentDidUpdate()
{ {
this.updateDimensions(nextProps); this.updateDimensions();
} }
render() render()
@ -82,8 +90,7 @@ class Peers extends React.Component
const { const {
advancedMode, advancedMode,
activeSpeakerName, activeSpeakerName,
peers, peers
toolAreaOpen
} = this.props; } = this.props;
const style = const style =
@ -93,7 +100,7 @@ class Peers extends React.Component
}; };
return ( return (
<div data-component='Peers' ref='peers'> <div data-component='Peers' ref={this.peersRef}>
{ {
peers.map((peer) => peers.map((peer) =>
{ {
@ -108,7 +115,6 @@ class Peers extends React.Component
advancedMode={advancedMode} advancedMode={advancedMode}
name={peer.name} name={peer.name}
style={style} style={style}
toolAreaOpen={toolAreaOpen}
/> />
</div> </div>
</Appear> </Appear>
@ -121,29 +127,24 @@ class Peers extends React.Component
} }
Peers.propTypes = Peers.propTypes =
{ {
advancedMode : PropTypes.bool, advancedMode : PropTypes.bool,
peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired, peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired,
videoStreams : PropTypes.any, boxes : PropTypes.number,
activeSpeakerName : PropTypes.string, activeSpeakerName : PropTypes.string
toolAreaOpen : PropTypes.bool };
};
const mapStateToProps = (state) => const mapStateToProps = (state) =>
{ {
const peersArray = Object.values(state.peers); const peers = Object.values(state.peers);
const videoStreamsArray = Object.values(state.consumers);
const videoStreams = const boxes = peers.length + Object.values(state.consumers)
videoStreamsArray.filter((consumer) => .filter((consumer) => consumer.source === 'screen').length;
{
return (consumer.source === 'webcam' || consumer.source === 'screen');
}).length;
return { return {
peers : peersArray, peers,
videoStreams : videoStreams, boxes,
activeSpeakerName : state.room.activeSpeakerName, activeSpeakerName : state.room.activeSpeakerName
toolAreaOpen : state.toolarea.toolAreaOpen
}; };
}; };

View File

@ -1,11 +1,12 @@
import React from 'react'; import React, { Fragment } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ReactTooltip from 'react-tooltip'; import ReactTooltip from 'react-tooltip';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import ClipboardButton from 'react-clipboard.js'; import CopyToClipboard from 'react-copy-to-clipboard';
import * as appPropTypes from './appPropTypes'; import * as appPropTypes from './appPropTypes';
import * as requestActions from '../redux/requestActions'; import * as requestActions from '../redux/requestActions';
import * as stateActions from '../redux/stateActions';
import { Appear } from './transitions'; import { Appear } from './transitions';
import Me from './Me'; import Me from './Me';
import Peers from './Peers'; import Peers from './Peers';
@ -14,207 +15,172 @@ import ToolAreaButton from './ToolArea/ToolAreaButton';
import ToolArea from './ToolArea/ToolArea'; import ToolArea from './ToolArea/ToolArea';
import FullScreenView from './FullScreenView'; import FullScreenView from './FullScreenView';
import Draggable from 'react-draggable'; import Draggable from 'react-draggable';
import { idle } from '../utils';
import Sidebar from './Sidebar';
import Filmstrip from './Filmstrip';
import { configureDragDrop, HoldingOverlay } from './FileSharing/DragDropSharing';
configureDragDrop();
// Hide toolbars after 10 seconds of inactivity.
const TIMEOUT = 10 * 1000;
class Room extends React.Component class Room extends React.Component
{ {
/**
* Hides the different toolbars on the page after a
* given amount of time has passed since the
* last time the cursor was moved.
*/
waitForHide = idle(() =>
{
this.props.setToolbarsVisible(false);
}, TIMEOUT);
handleMovement = () =>
{
// If the toolbars were hidden, show them again when
// the user moves their cursor.
if (!this.props.room.toolbarsVisible)
{
this.props.setToolbarsVisible(true);
}
this.waitForHide();
}
componentDidMount()
{
window.addEventListener('mousemove', this.handleMovement);
window.addEventListener('touchstart', this.handleMovement);
}
componentWillUnmount()
{
window.removeEventListener('mousemove', this.handleMovement);
window.removeEventListener('touchstart', this.handleMovement);
}
render() render()
{ {
const { const {
room, room,
me,
toolAreaOpen, toolAreaOpen,
amActiveSpeaker, amActiveSpeaker,
screenProducer, onRoomLinkCopy
onRoomLinkCopy,
onLogin,
onShareScreen,
onUnShareScreen,
onNeedExtension,
onLeaveMeeting
} = this.props; } = this.props;
let screenState; const View = {
let screenTip; filmstrip : Filmstrip,
democratic : Peers
if (me.needExtension) }[room.mode];
{
screenState = 'need-extension';
screenTip = 'Install screen sharing extension';
}
else if (!me.canShareScreen)
{
screenState = 'unsupported';
screenTip = 'Screen sharing not supported';
}
else if (screenProducer)
{
screenState = 'on';
screenTip = 'Stop screen sharing';
}
else
{
screenState = 'off';
screenTip = 'Start screen sharing';
}
return ( return (
<Appear duration={300}> <Fragment>
<div data-component='Room'> <HoldingOverlay />
<FullScreenView advancedMode={room.advancedMode} />
<div
className='room-wrapper'
style={{
width : toolAreaOpen ? '80%' : '100%'
}}
>
<Notifications />
<ToolAreaButton />
{room.advancedMode ? <Appear duration={300}>
<div className='state' data-tip='Server status'> <div data-component='Room'>
<div className={classnames('icon', room.state)} /> <FullScreenView advancedMode={room.advancedMode} />
<p className={classnames('text', room.state)}>{room.state}</p> <div className='room-wrapper'>
</div> <Notifications />
:null
}
<div className='room-link-wrapper'> <ToolAreaButton />
<div className='room-link'>
<ClipboardButton
component='a'
className='link'
button-href={room.url}
button-target='_blank'
data-tip='Click to copy room link'
data-clipboard-text={room.url}
onSuccess={onRoomLinkCopy}
onClick={(event) =>
{
// If this is a 'Open in new window/tab' don't prevent
// click default action.
if (
event.ctrlKey || event.shiftKey || event.metaKey ||
// Middle click (IE > 9 and everyone else).
(event.button && event.button === 1)
)
{
return;
}
event.preventDefault(); {room.advancedMode ?
}} <div className='state' data-tip='Server status'>
> <div className={classnames('icon', room.state)} />
invitation link <p className={classnames('text', room.state)}>{room.state}</p>
</ClipboardButton> </div>
</div>
</div>
<Peers
advancedMode={room.advancedMode}
/>
<Draggable handle='.me-container' bounds='body' cancel='.display-name'>
<div
className={classnames('me-container', {
'active-speaker' : amActiveSpeaker
})}
>
<Me
advancedMode={room.advancedMode}
/>
</div>
</Draggable>
<div className='sidebar'>
<div
className={classnames('button', 'screen', screenState)}
data-tip={screenTip}
data-type='dark'
onClick={() =>
{
switch (screenState)
{
case 'on':
{
onUnShareScreen();
break;
}
case 'off':
{
onShareScreen();
break;
}
case 'need-extension':
{
onNeedExtension();
break;
}
default:
{
break;
}
}
}}
/>
{me.loginEnabled ?
<div
className={classnames('button', 'login', 'off', {
disabled : me.loginInProgress
})}
data-tip='Login'
data-type='dark'
onClick={() => onLogin()}
/>
:null :null
} }
<div <div
className={classnames('button', 'leave-meeting')} className={classnames('room-link-wrapper room-controls', {
data-tip='Leave meeting' 'visible' : this.props.room.toolbarsVisible
data-type='dark' })}
onClick={() => onLeaveMeeting()} >
<div className='room-link'>
<CopyToClipboard
text={room.url}
onCopy={onRoomLinkCopy}
>
<a
className='link'
href={room.url}
target='_blank'
data-tip='Click to copy room link'
rel='noopener noreferrer'
onClick={(event) =>
{
// If this is a 'Open in new window/tab' don't prevent
// click default action.
if (
event.ctrlKey || event.shiftKey || event.metaKey ||
// Middle click (IE > 9 and everyone else).
(event.button && event.button === 1)
)
{
return;
}
event.preventDefault();
}}
>
invitation link
</a>
</CopyToClipboard>
</div>
</div>
<View advancedMode={room.advancedMode} />
<Draggable handle='.me-container' bounds='body' cancel='.display-name'>
<div
className={classnames('me-container', {
'active-speaker' : amActiveSpeaker
})}
>
<Me
advancedMode={room.advancedMode}
/>
</div>
</Draggable>
<Sidebar />
<ReactTooltip
effect='solid'
delayShow={100}
delayHide={100}
/> />
</div> </div>
<div
<ReactTooltip className={classnames('toolarea-wrapper', { open: toolAreaOpen })}
effect='solid' >
delayShow={100} {toolAreaOpen ?
delayHide={100} <ToolArea
/> advancedMode={room.advancedMode}
/>
:null
}
</div>
</div> </div>
<div </Appear>
className='toolarea-wrapper' </Fragment>
style={{
width : toolAreaOpen ? '20%' : '0%'
}}
>
{toolAreaOpen ?
<ToolArea
advancedMode={room.advancedMode}
/>
:null
}
</div>
</div>
</Appear>
); );
} }
} }
Room.propTypes = Room.propTypes =
{ {
room : appPropTypes.Room.isRequired, room : appPropTypes.Room.isRequired,
me : appPropTypes.Me.isRequired, me : appPropTypes.Me.isRequired,
amActiveSpeaker : PropTypes.bool.isRequired, amActiveSpeaker : PropTypes.bool.isRequired,
toolAreaOpen : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired,
screenProducer : appPropTypes.Producer, screenProducer : appPropTypes.Producer,
onRoomLinkCopy : PropTypes.func.isRequired, onRoomLinkCopy : PropTypes.func.isRequired,
onShareScreen : PropTypes.func.isRequired, setToolbarsVisible : PropTypes.func.isRequired
onUnShareScreen : PropTypes.func.isRequired,
onNeedExtension : PropTypes.func.isRequired,
onLeaveMeeting : PropTypes.func.isRequired,
onLogin : PropTypes.func.isRequired
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
@ -242,25 +208,10 @@ const mapDispatchToProps = (dispatch) =>
text : 'Room link copied to the clipboard' text : 'Room link copied to the clipboard'
})); }));
}, },
onLeaveMeeting : () =>
setToolbarsVisible : (visible) =>
{ {
dispatch(requestActions.leaveRoom()); dispatch(stateActions.setToolbarsVisible(visible));
},
onShareScreen : () =>
{
dispatch(requestActions.enableScreenSharing());
},
onUnShareScreen : () =>
{
dispatch(requestActions.disableScreenSharing());
},
onNeedExtension : () =>
{
dispatch(requestActions.installExtension());
},
onLogin : () =>
{
dispatch(requestActions.userLogin());
} }
}; };
}; };

View File

@ -6,75 +6,83 @@ import * as stateActions from '../redux/stateActions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Dropdown from 'react-dropdown'; import Dropdown from 'react-dropdown';
class Settings extends React.Component const modes = [ {
value : 'democratic',
label : 'Democratic view'
}, {
value : 'filmstrip',
label : 'Filmstrip view'
} ];
const findOption = (options, value) => options.find((option) => option.value === value);
const Settings = ({
room, me, onToggleAdvancedMode, handleChangeWebcam,
handleChangeAudioDevice, handleChangeMode
}) =>
{ {
constructor(props) let webcams;
{ let webcamText;
super(props);
}
render() if (me.canChangeWebcam)
{ webcamText = 'Select camera';
const { else
room, webcamText = 'Unable to select camera';
me,
handleChangeWebcam,
handleChangeAudioDevice,
onToggleAdvancedMode
} = this.props;
let webcams; if (me.webcamDevices)
let webcamText; webcams = Array.from(me.webcamDevices.values());
else
webcams = [];
if (me.canChangeWebcam) let audioDevices;
webcamText = 'Select camera'; let audioDevicesText;
else
webcamText = 'Unable to select camera';
if (me.webcamDevices) if (me.canChangeAudioDevice)
webcams = Array.from(me.webcamDevices.values()); audioDevicesText = 'Select audio input device';
else else
webcams = []; audioDevicesText = 'Unable to select audio input device';
let audioDevices; if (me.audioDevices)
let audioDevicesText; audioDevices = Array.from(me.audioDevices.values());
else
audioDevices = [];
if (me.canChangeAudioDevice) return (
audioDevicesText = 'Select audio input device'; <div data-component='Settings'>
else <div className='settings'>
audioDevicesText = 'Unable to select audio input device'; <Dropdown
disabled={!me.canChangeWebcam}
options={webcams}
value={findOption(webcams, me.selectedWebcam)}
onChange={(webcam) => handleChangeWebcam(webcam.value)}
placeholder={webcamText}
/>
if (me.audioDevices) <Dropdown
audioDevices = Array.from(me.audioDevices.values()); disabled={!me.canChangeAudioDevice}
else options={audioDevices}
audioDevices = []; value={findOption(audioDevices, me.selectedAudioDevice)}
onChange={(device) => handleChangeAudioDevice(device.value)}
placeholder={audioDevicesText}
/>
return ( <input
<div data-component='Settings'> id='room-mode'
<div className='settings'> type='checkbox'
<Dropdown checked={room.advancedMode}
disabled={!me.canChangeWebcam} onChange={onToggleAdvancedMode}
options={webcams} />
onChange={handleChangeWebcam} <label htmlFor='room-mode'>Advanced mode</label>
placeholder={webcamText}
/> <Dropdown
<Dropdown options={modes}
disabled={!me.canChangeAudioDevice} value={findOption(modes, room.mode)}
options={audioDevices} onChange={(mode) => handleChangeMode(mode.value)}
onChange={handleChangeAudioDevice} />
placeholder={audioDevicesText}
/>
<input
type='checkbox'
defaultChecked={room.advancedMode}
onChange={onToggleAdvancedMode}
/>
<span>Advanced mode</span>
</div>
</div> </div>
); </div>
} );
} };
Settings.propTypes = Settings.propTypes =
{ {
@ -82,7 +90,8 @@ Settings.propTypes =
room : appPropTypes.Room.isRequired, room : appPropTypes.Room.isRequired,
handleChangeWebcam : PropTypes.func.isRequired, handleChangeWebcam : PropTypes.func.isRequired,
handleChangeAudioDevice : PropTypes.func.isRequired, handleChangeAudioDevice : PropTypes.func.isRequired,
onToggleAdvancedMode : PropTypes.func.isRequired onToggleAdvancedMode : PropTypes.func.isRequired,
handleChangeMode : PropTypes.func.isRequired
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
@ -93,22 +102,11 @@ const mapStateToProps = (state) =>
}; };
}; };
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = {
{ handleChangeWebcam : requestActions.changeWebcam,
return { handleChangeAudioDevice : requestActions.changeAudioDevice,
handleChangeWebcam : (device) => onToggleAdvancedMode : stateActions.toggleAdvancedMode,
{ handleChangeMode : stateActions.setDisplayMode
dispatch(requestActions.changeWebcam(device.value));
},
handleChangeAudioDevice : (device) =>
{
dispatch(requestActions.changeAudioDevice(device.value));
},
onToggleAdvancedMode : () =>
{
dispatch(stateActions.toggleAdvancedMode());
}
};
}; };
const SettingsContainer = connect( const SettingsContainer = connect(

View File

@ -0,0 +1,202 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classnames from 'classnames';
import * as appPropTypes from './appPropTypes';
import * as requestActions from '../redux/requestActions';
import fscreen from 'fscreen';
class Sidebar extends Component
{
state = {
fullscreen : false
};
handleToggleFullscreen = () =>
{
if (fscreen.fullscreenElement)
{
fscreen.exitFullscreen();
}
else
{
fscreen.requestFullscreen(document.documentElement);
}
};
handleFullscreenChange = () =>
{
this.setState({
fullscreen : fscreen.fullscreenElement !== null
});
};
componentDidMount()
{
if (fscreen.fullscreenEnabled)
{
fscreen.addEventListener('fullscreenchange', this.handleFullscreenChange);
}
}
componentWillUnmount()
{
if (fscreen.fullscreenEnabled)
{
fscreen.removeEventListener('fullscreenchange', this.handleFullscreenChange);
}
}
render()
{
const {
toolbarsVisible, me, screenProducer, onLogin, onShareScreen,
onUnShareScreen, onNeedExtension, onLeaveMeeting, onLogout, onToggleHand
} = this.props;
let screenState;
let screenTip;
if (me.needExtension)
{
screenState = 'need-extension';
screenTip = 'Install screen sharing extension';
}
else if (!me.canShareScreen)
{
screenState = 'unsupported';
screenTip = 'Screen sharing not supported';
}
else if (screenProducer)
{
screenState = 'on';
screenTip = 'Stop screen sharing';
}
else
{
screenState = 'off';
screenTip = 'Start screen sharing';
}
return (
<div
className={classnames('sidebar room-controls', {
'visible' : toolbarsVisible
})}
data-component='Sidebar'
>
{fscreen.fullscreenEnabled && (
<div
className={classnames('button', 'fullscreen', {
on : this.state.fullscreen
})}
onClick={this.handleToggleFullscreen}
data-tip='Fullscreen'
data-type='dark'
/>
)}
<div
className={classnames('button', 'screen', screenState)}
data-tip={screenTip}
data-type='dark'
onClick={() =>
{
switch (screenState)
{
case 'on':
{
onUnShareScreen();
break;
}
case 'off':
{
onShareScreen();
break;
}
case 'need-extension':
{
onNeedExtension();
break;
}
default:
{
break;
}
}
}}
/>
{me.loginEnabled && (me.loggedIn ? (
<div
className='button logout'
data-tip='Logout'
data-type='dark'
onClick={onLogout}
>
<img src={me.picture || 'resources/images/avatar-empty.jpeg'} />
</div>
) : (
<div
className='button login off'
data-tip='Login'
data-type='dark'
onClick={onLogin}
/>
))}
<div
className={classnames('button', 'raise-hand', {
on : me.raiseHand,
disabled : me.raiseHandInProgress
})}
data-tip='Raise hand'
data-type='dark'
onClick={() => onToggleHand(!me.raiseHand)}
/>
<div
className={classnames('button', 'leave-meeting')}
data-tip='Leave meeting'
data-type='dark'
onClick={() => onLeaveMeeting()}
/>
</div>
);
}
}
Sidebar.propTypes = {
toolbarsVisible : PropTypes.bool.isRequired,
me : appPropTypes.Me.isRequired,
onShareScreen : PropTypes.func.isRequired,
onUnShareScreen : PropTypes.func.isRequired,
onNeedExtension : PropTypes.func.isRequired,
onToggleHand : PropTypes.func.isRequired,
onLeaveMeeting : PropTypes.func.isRequired,
onLogin : PropTypes.func.isRequired,
onLogout : PropTypes.func.isRequired,
screenProducer : appPropTypes.Producer
};
const mapStateToProps = (state) =>
({
toolbarsVisible : state.room.toolbarsVisible,
screenProducer : Object.values(state.producers)
.find((producer) => producer.source === 'screen'),
me : state.me
});
const mapDispatchToProps = {
onLeaveMeeting : requestActions.leaveRoom,
onShareScreen : requestActions.enableScreenSharing,
onUnShareScreen : requestActions.disableScreenSharing,
onNeedExtension : requestActions.installExtension,
onToggleHand : requestActions.toggleHand,
onLogin : requestActions.userLogin,
onLogout : requestActions.userLogout
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Sidebar);

View File

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as stateActions from '../../redux/stateActions'; import * as toolTabActions from '../../redux/stateActions';
import ParticipantList from '../ParticipantList/ParticipantList'; import ParticipantList from '../ParticipantList/ParticipantList';
import Chat from '../Chat/Chat'; import Chat from '../Chat/Chat';
import Settings from '../Settings'; import Settings from '../Settings';
import FileSharing from '../FileSharing';
class ToolArea extends React.Component class ToolArea extends React.Component
{ {
@ -16,7 +17,9 @@ class ToolArea extends React.Component
render() render()
{ {
const { const {
toolarea, currentToolTab,
unreadMessages,
unreadFiles,
setToolTab setToolTab
} = this.props; } = this.props;
@ -31,14 +34,39 @@ class ToolArea extends React.Component
{ {
setToolTab('chat'); setToolTab('chat');
}} }}
checked={toolarea.currentToolTab === 'chat'} checked={currentToolTab === 'chat'}
/> />
<label htmlFor='tab-chat'>Chat</label> <label htmlFor='tab-chat'>
Chat
{unreadMessages > 0 && (
<span className='badge'>{unreadMessages}</span>
)}
</label>
<div className='tab'> <div className='tab'>
<Chat /> <Chat />
</div> </div>
<input
type='radio'
name='tabs'
id='tab-files'
onChange={() => setToolTab('files')}
checked={currentToolTab === 'files'}
/>
<label htmlFor='tab-files'>
Files
{unreadFiles > 0 && (
<span className='badge'>{unreadFiles}</span>
)}
</label>
<div className='tab'>
<FileSharing />
</div>
<input <input
type='radio' type='radio'
name='tabs' name='tabs'
@ -47,7 +75,7 @@ class ToolArea extends React.Component
{ {
setToolTab('users'); setToolTab('users');
}} }}
checked={toolarea.currentToolTab === 'users'} checked={currentToolTab === 'users'}
/> />
<label htmlFor='tab-users'>Users</label> <label htmlFor='tab-users'>Users</label>
@ -63,7 +91,7 @@ class ToolArea extends React.Component
{ {
setToolTab('settings'); setToolTab('settings');
}} }}
checked={toolarea.currentToolTab === 'settings'} checked={currentToolTab === 'settings'}
/> />
<label htmlFor='tab-settings'>Settings</label> <label htmlFor='tab-settings'>Settings</label>
@ -78,26 +106,21 @@ class ToolArea extends React.Component
ToolArea.propTypes = ToolArea.propTypes =
{ {
advancedMode : PropTypes.bool, advancedMode : PropTypes.bool,
toolarea : PropTypes.object.isRequired, currentToolTab : PropTypes.string.isRequired,
setToolTab : PropTypes.func.isRequired setToolTab : PropTypes.func.isRequired,
unreadMessages : PropTypes.number.isRequired,
unreadFiles : PropTypes.number.isRequired
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) => ({
{ currentToolTab : state.toolarea.currentToolTab,
return { unreadMessages : state.toolarea.unreadMessages,
toolarea : state.toolarea unreadFiles : state.toolarea.unreadFiles
}; });
};
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = {
{ setToolTab : toolTabActions.setToolTab
return {
setToolTab : (toolTab) =>
{
dispatch(stateActions.setToolTab(toolTab));
}
};
}; };
const ToolAreaContainer = connect( const ToolAreaContainer = connect(

View File

@ -10,20 +10,28 @@ class ToolAreaButton extends React.Component
{ {
const { const {
toolAreaOpen, toolAreaOpen,
toggleToolArea toggleToolArea,
unread
} = this.props; } = this.props;
return ( return (
<div data-component='ToolAreaButton'> <div data-component='ToolAreaButton' className={classnames({ on: toolAreaOpen })}>
<div <div
className={classnames('button', 'toolarea-button', { className={classnames('button toolarea-button room-controls', {
on : toolAreaOpen on : toolAreaOpen,
visible : this.props.visible
})} })}
data-tip='Toggle tool area' data-tip='Toggle tool area'
data-type='dark' data-type='dark'
data-for='globaltip' data-for='globaltip'
onClick={() => toggleToolArea()} onClick={() => toggleToolArea()}
/> />
{!toolAreaOpen && unread > 0 && (
<span className={classnames('badge', { long: unread >= 10 })}>
{unread}
</span>
)}
</div> </div>
); );
} }
@ -32,13 +40,17 @@ class ToolAreaButton extends React.Component
ToolAreaButton.propTypes = ToolAreaButton.propTypes =
{ {
toolAreaOpen : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired,
toggleToolArea : PropTypes.func.isRequired toggleToolArea : PropTypes.func.isRequired,
unread : PropTypes.number.isRequired,
visible : PropTypes.bool.isRequired
}; };
const mapStateToProps = (state) => const mapStateToProps = (state) =>
{ {
return { return {
toolAreaOpen : state.toolarea.toolAreaOpen toolAreaOpen : state.toolarea.toolAreaOpen,
visible : state.room.toolbarsVisible,
unread : state.toolarea.unreadMessages + state.toolarea.unreadFiles
}; };
}; };

View File

@ -3,13 +3,6 @@ import UrlParse from 'url-parse';
import React from 'react'; import React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import {
applyMiddleware as applyReduxMiddleware,
createStore as createReduxStore,
compose as composeRedux
} from 'redux';
import thunk from 'redux-thunk';
import { createLogger as createReduxLogger } from 'redux-logger';
import { getDeviceInfo } from 'mediasoup-client'; import { getDeviceInfo } from 'mediasoup-client';
import randomString from 'random-string'; import randomString from 'random-string';
import Logger from './Logger'; import Logger from './Logger';
@ -17,48 +10,11 @@ import * as utils from './utils';
import * as cookiesManager from './cookiesManager'; import * as cookiesManager from './cookiesManager';
import * as requestActions from './redux/requestActions'; import * as requestActions from './redux/requestActions';
import * as stateActions from './redux/stateActions'; import * as stateActions from './redux/stateActions';
import reducers from './redux/reducers';
import roomClientMiddleware from './redux/roomClientMiddleware';
import Room from './components/Room'; import Room from './components/Room';
import { loginEnabled } from '../config'; import { loginEnabled } from '../config';
import { store } from './store';
const logger = new Logger(); const logger = new Logger();
const reduxMiddlewares =
[
thunk,
roomClientMiddleware
];
if (process.env.NODE_ENV === 'development')
{
const reduxLogger = createReduxLogger(
{
duration : true,
timestamp : false,
level : 'log',
logErrors : true
});
reduxMiddlewares.push(reduxLogger);
}
const composeEnhancers =
typeof window === 'object' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
// Specify extensions options like name, actionsBlacklist, actionsCreators, serialize...
}) : composeRedux;
const enhancer = composeEnhancers(
applyReduxMiddleware(...reduxMiddlewares)
// other store enhancers if any
);
const store = createReduxStore(
reducers,
undefined,
enhancer
);
domready(() => domready(() =>
{ {

View File

@ -3,9 +3,7 @@ import
createNewMessage createNewMessage
} from './helper'; } from './helper';
const initialState = []; const chatmessages = (state = [], action) =>
const chatmessages = (state = initialState, action) =>
{ {
switch (action.type) switch (action.type)
{ {
@ -13,7 +11,7 @@ const chatmessages = (state = initialState, action) =>
{ {
const { text } = action.payload; const { text } = action.payload;
const message = createNewMessage(text, 'client', 'Me'); const message = createNewMessage(text, 'client', 'Me', undefined);
return [ ...state, message ]; return [ ...state, message ];
} }

View File

@ -35,6 +35,15 @@ const consumers = (state = initialState, action) =>
return { ...state, [consumerId]: newConsumer }; return { ...state, [consumerId]: newConsumer };
} }
case 'SET_CONSUMER_VOLUME':
{
const { consumerId, volume } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, volume };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_RESUMED': case 'SET_CONSUMER_RESUMED':
{ {
const { consumerId, originator } = action.payload; const { consumerId, originator } = action.payload;

View File

@ -1,10 +1,11 @@
export function createNewMessage(text, sender, name) export function createNewMessage(text, sender, name, picture)
{ {
return { return {
type : 'message', type : 'message',
text, text,
time : Date.now(), time : Date.now(),
name, name,
sender sender,
picture
}; };
} }

View File

@ -8,6 +8,7 @@ import notifications from './notifications';
import chatmessages from './chatmessages'; import chatmessages from './chatmessages';
import chatbehavior from './chatbehavior'; import chatbehavior from './chatbehavior';
import toolarea from './toolarea'; import toolarea from './toolarea';
import sharing from './sharing';
const reducers = combineReducers( const reducers = combineReducers(
{ {
@ -19,7 +20,8 @@ const reducers = combineReducers(
notifications, notifications,
chatmessages, chatmessages,
chatbehavior, chatbehavior,
toolarea toolarea,
sharing
}); });
export default reducers; export default reducers;

View File

@ -15,13 +15,16 @@ const initialState =
webcamInProgress : false, webcamInProgress : false,
audioInProgress : false, audioInProgress : false,
screenShareInProgress : false, screenShareInProgress : false,
loginInProgress : false,
loginEnabled : false, loginEnabled : false,
audioOnly : false, audioOnly : false,
audioOnlyInProgress : false, audioOnlyInProgress : false,
raiseHand : false, raiseHand : false,
raiseHandInProgress : false, raiseHandInProgress : false,
restartIceInProgress : false restartIceInProgress : false,
picture : null,
selectedWebcam : null,
selectedAudioDevice : null,
loggedIn : false
}; };
const me = (state = initialState, action) => const me = (state = initialState, action) =>
@ -48,6 +51,22 @@ const me = (state = initialState, action) =>
}; };
} }
case 'LOGGED_IN':
return { ...state, loggedIn: true };
case 'USER_LOGOUT':
return { ...state, loggedIn: false };
case 'CHANGE_WEBCAM':
{
return { ...state, selectedWebcam: action.payload.deviceId };
}
case 'CHANGE_AUDIO_DEVICE':
{
return { ...state, selectedAudioDevice: action.payload.deviceId };
}
case 'SET_MEDIA_CAPABILITIES': case 'SET_MEDIA_CAPABILITIES':
{ {
const { canSendMic, canSendWebcam } = action.payload; const { canSendMic, canSendWebcam } = action.payload;
@ -111,13 +130,6 @@ const me = (state = initialState, action) =>
return { ...state, screenShareInProgress: flag }; return { ...state, screenShareInProgress: flag };
} }
case 'SET_LOGIN_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, loginInProgress: flag };
}
case 'SET_DISPLAY_NAME': case 'SET_DISPLAY_NAME':
{ {
let { displayName } = action.payload; let { displayName } = action.payload;
@ -164,6 +176,11 @@ const me = (state = initialState, action) =>
return { ...state, restartIceInProgress: flag }; return { ...state, restartIceInProgress: flag };
} }
case 'SET_PICTURE':
{
return { ...state, picture: action.payload.picture };
}
default: default:
return state; return state;
} }

View File

@ -1,126 +1,93 @@
const initialState = {}; import omit from 'lodash/omit';
const peers = (state = initialState, action) => const peer = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_PEER':
return action.payload.peer;
case 'SET_PEER_DISPLAY_NAME':
return { ...state, displayName: action.payload.displayName };
case 'SET_PEER_VIDEO_IN_PROGRESS':
return { ...state, peerVideoInProgress: action.payload.flag };
case 'SET_PEER_AUDIO_IN_PROGRESS':
return { ...state, peerAudioInProgress: action.payload.flag };
case 'SET_PEER_SCREEN_IN_PROGRESS':
return { ...state, peerScreenInProgress: action.payload.flag };
case 'SET_PEER_RAISE_HAND_STATE':
return { ...state, raiseHandState: action.payload.raiseHandState };
case 'ADD_CONSUMER':
{
const consumers = [ ...state.consumers, action.payload.consumer.id ];
return { ...state, consumers };
}
case 'REMOVE_CONSUMER':
{
const consumers = state.consumers.filter((consumer) =>
consumer !== action.payload.consumerId);
return { ...state, consumers };
}
case 'SET_PEER_PICTURE':
{
return { ...state, picture: action.payload.picture };
}
default:
return state;
}
};
const peers = (state = {}, action) =>
{ {
switch (action.type) switch (action.type)
{ {
case 'ADD_PEER': case 'ADD_PEER':
{ {
const { peer } = action.payload; return { ...state, [action.payload.peer.name]: peer(undefined, action) };
return { ...state, [peer.name]: peer };
} }
case 'REMOVE_PEER': case 'REMOVE_PEER':
{ {
const { peerName } = action.payload; return omit(state, [ action.payload.peerName ]);
const newState = { ...state };
delete newState[peerName];
return newState;
} }
case 'SET_PEER_DISPLAY_NAME': case 'SET_PEER_DISPLAY_NAME':
{
const { displayName, peerName } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, displayName };
return { ...state, [newPeer.name]: newPeer };
}
case 'SET_PEER_VIDEO_IN_PROGRESS': case 'SET_PEER_VIDEO_IN_PROGRESS':
{
const { peerName, flag } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, peerVideoInProgress: flag };
return { ...state, [newPeer.name]: newPeer };
}
case 'SET_PEER_AUDIO_IN_PROGRESS': case 'SET_PEER_AUDIO_IN_PROGRESS':
{
const { peerName, flag } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, peerAudioInProgress: flag };
return { ...state, [newPeer.name]: newPeer };
}
case 'SET_PEER_SCREEN_IN_PROGRESS': case 'SET_PEER_SCREEN_IN_PROGRESS':
{
const { peerName, flag } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, peerScreenInProgress: flag };
return { ...state, [newPeer.name]: newPeer };
}
case 'SET_PEER_RAISE_HAND_STATE': case 'SET_PEER_RAISE_HAND_STATE':
{ case 'SET_PEER_PICTURE':
const { peerName, raiseHandState } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, raiseHandState };
return { ...state, [newPeer.name]: newPeer };
}
case 'ADD_CONSUMER': case 'ADD_CONSUMER':
{ {
const { consumer, peerName } = action.payload; const oldPeer = state[action.payload.peerName];
const peer = state[peerName];
if (!peer) if (!oldPeer)
throw new Error('no Peer found for new Consumer'); {
throw new Error('no Peer found');
}
const newConsumers = [ ...peer.consumers, consumer.id ]; return { ...state, [oldPeer.name]: peer(oldPeer, action) };
const newPeer = { ...peer, consumers: newConsumers };
return { ...state, [newPeer.name]: newPeer };
} }
case 'REMOVE_CONSUMER': case 'REMOVE_CONSUMER':
{ {
const { consumerId, peerName } = action.payload; const oldPeer = state[action.payload.peerName];
const peer = state[peerName];
// NOTE: This means that the Peer was closed before, so it's ok. // NOTE: This means that the Peer was closed before, so it's ok.
if (!peer) if (!oldPeer)
return state; return state;
const idx = peer.consumers.indexOf(consumerId); return { ...state, [oldPeer.name]: peer(oldPeer, action) };
if (idx === -1)
throw new Error('Consumer not found');
const newConsumers = peer.consumers.slice();
newConsumers.splice(idx, 1);
const newPeer = { ...peer, consumers: newConsumers };
return { ...state, [newPeer.name]: newPeer };
} }
default: default:

View File

@ -35,6 +35,15 @@ const producers = (state = initialState, action) =>
return { ...state, [producerId]: newProducer }; return { ...state, [producerId]: newProducer };
} }
case 'SET_PRODUCER_VOLUME':
{
const { producerId, volume } = action.payload;
const producer = state[producerId];
const newProducer = { ...producer, volume };
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_RESUMED': case 'SET_PRODUCER_RESUMED':
{ {
const { producerId, originator } = action.payload; const { producerId, originator } = action.payload;

View File

@ -5,7 +5,10 @@ const initialState =
activeSpeakerName : null, activeSpeakerName : null,
showSettings : false, showSettings : false,
advancedMode : false, advancedMode : false,
fullScreenConsumer : null // ConsumerID fullScreenConsumer : null, // ConsumerID
toolbarsVisible : true,
mode : 'democratic',
selectedPeerName : null
}; };
const room = (state = initialState, action) => const room = (state = initialState, action) =>
@ -58,6 +61,28 @@ const room = (state = initialState, action) =>
return { ...state, fullScreenConsumer: currentConsumer ? null : consumerId }; return { ...state, fullScreenConsumer: currentConsumer ? null : consumerId };
} }
case 'SET_TOOLBARS_VISIBLE':
{
const { toolbarsVisible } = action.payload;
return { ...state, toolbarsVisible };
}
case 'SET_DISPLAY_MODE':
return { ...state, mode: action.payload.mode };
case 'SET_SELECTED_PEER':
{
const { selectedPeerName } = action.payload;
return {
...state,
selectedPeerName : state.selectedPeerName === selectedPeerName ?
null : selectedPeerName
};
}
default: default:
return state; return state;
} }

View File

@ -0,0 +1,19 @@
const sharing = (state = [], action) =>
{
switch (action.type)
{
case 'SEND_FILE':
return [ ...state, { ...action.payload, me: true } ];
case 'ADD_FILE':
return [ ...state, action.payload ];
case 'ADD_FILE_HISTORY':
return [ ...action.payload.fileHistory, ...state ];
default:
return state;
}
};
export default sharing;

View File

@ -1,7 +1,9 @@
const initialState = const initialState =
{ {
toolAreaOpen : false, toolAreaOpen : false,
currentToolTab : 'chat' // chat, settings, users currentToolTab : 'chat', // chat, settings, users
unreadMessages : 0,
unreadFiles : 0
}; };
const toolarea = (state = initialState, action) => const toolarea = (state = initialState, action) =>
@ -11,15 +13,39 @@ const toolarea = (state = initialState, action) =>
case 'TOGGLE_TOOL_AREA': case 'TOGGLE_TOOL_AREA':
{ {
const toolAreaOpen = !state.toolAreaOpen; const toolAreaOpen = !state.toolAreaOpen;
const unreadMessages = toolAreaOpen && state.currentToolTab === 'chat' ? 0 : state.unreadMessages;
const unreadFiles = toolAreaOpen && state.currentToolTab === 'files' ? 0 : state.unreadFiles;
return { ...state, toolAreaOpen }; return { ...state, toolAreaOpen, unreadMessages, unreadFiles };
} }
case 'SET_TOOL_TAB': case 'SET_TOOL_TAB':
{ {
const { toolTab } = action.payload; const { toolTab } = action.payload;
const unreadMessages = toolTab === 'chat' ? 0 : state.unreadMessages;
const unreadFiles = toolTab === 'files' ? 0 : state.unreadFiles;
return { ...state, currentToolTab: toolTab }; return { ...state, currentToolTab: toolTab, unreadMessages, unreadFiles };
}
case 'ADD_NEW_RESPONSE_MESSAGE':
{
if (state.toolAreaOpen && state.currentToolTab === 'chat')
{
return state;
}
return { ...state, unreadMessages: state.unreadMessages + 1 };
}
case 'ADD_FILE':
{
if (state.toolAreaOpen && state.currentToolTab === 'files')
{
return state;
}
return { ...state, unreadFiles: state.unreadFiles + 1 };
} }
default: default:

View File

@ -142,6 +142,13 @@ export const userLogin = () =>
}; };
}; };
export const userLogout = () =>
{
return {
type : 'USER_LOGOUT'
};
};
export const raiseHand = () => export const raiseHand = () =>
{ {
return { return {
@ -184,9 +191,21 @@ export const installExtension = () =>
}; };
}; };
export const sendChatMessage = (text, name) => export const toggleHand = (enable) =>
{ {
const message = createNewMessage(text, 'response', name); if (enable)
return {
type : 'RAISE_HAND'
};
else
return {
type : 'LOWER_HAND'
};
};
export const sendChatMessage = (text, name, picture) =>
{
const message = createNewMessage(text, 'response', name, picture);
return { return {
type : 'SEND_CHAT_MESSAGE', type : 'SEND_CHAT_MESSAGE',
@ -194,6 +213,14 @@ export const sendChatMessage = (text, name) =>
}; };
}; };
export const sendFile = (file, name, picture) =>
{
return {
type : 'SEND_FILE',
payload : { file, name, picture }
};
};
// This returns a redux-thunk action (a function). // This returns a redux-thunk action (a function).
export const notify = ({ type = 'info', text, timeout }) => export const notify = ({ type = 'info', text, timeout }) =>
{ {

View File

@ -181,6 +181,13 @@ export default ({ dispatch, getState }) => (next) =>
break; break;
} }
case 'USER_LOGOUT':
{
client.logout();
break;
}
case 'LOWER_HAND': case 'LOWER_HAND':
{ {
client.sendRaiseHandState(false); client.sendRaiseHandState(false);
@ -224,6 +231,12 @@ export default ({ dispatch, getState }) => (next) =>
break; break;
} }
case 'SEND_FILE':
{
client.sendFile(action.payload);
break;
}
} }
return next(action); return next(action);

View File

@ -93,6 +93,12 @@ export const toggleAdvancedMode = () =>
}; };
}; };
export const setDisplayMode = (mode) =>
({
type : 'SET_DISPLAY_MODE',
payload : { mode }
});
export const setAudioOnlyState = (enabled) => export const setAudioOnlyState = (enabled) =>
{ {
return { return {
@ -141,14 +147,6 @@ export const setMyRaiseHandState = (flag) =>
}; };
}; };
export const setLoginInProgress = (flag) =>
{
return {
type : 'SET_LOGIN_IN_PROGRESS',
payload : { flag }
};
};
export const toggleSettings = () => export const toggleSettings = () =>
{ {
return { return {
@ -331,6 +329,22 @@ export const setConsumerTrack = (consumerId, track) =>
}; };
}; };
export const setConsumerVolume = (consumerId, volume) =>
{
return {
type : 'SET_CONSUMER_VOLUME',
payload : { consumerId, volume }
};
};
export const setProducerVolume = (producerId, volume) =>
{
return {
type : 'SET_PRODUCER_VOLUME',
payload : { producerId, volume }
};
};
export const addNotification = (notification) => export const addNotification = (notification) =>
{ {
return { return {
@ -369,6 +383,11 @@ export const toggleConsumerFullscreen = (consumerId) =>
}; };
}; };
export const setToolbarsVisible = (toolbarsVisible) => ({
type : 'SET_TOOLBARS_VISIBLE',
payload : { toolbarsVisible }
});
export const increaseBadge = () => export const increaseBadge = () =>
{ {
return { return {
@ -391,6 +410,14 @@ export const addUserMessage = (text) =>
}; };
}; };
export const addUserFile = (file) =>
{
return {
type : 'ADD_NEW_USER_FILE',
payload : { file }
};
};
export const addResponseMessage = (message) => export const addResponseMessage = (message) =>
{ {
return { return {
@ -413,3 +440,41 @@ export const dropMessages = () =>
type : 'DROP_MESSAGES' type : 'DROP_MESSAGES'
}; };
}; };
export const addFile = (payload) =>
{
return {
type : 'ADD_FILE',
payload
};
};
export const addFileHistory = (fileHistory) =>
{
return {
type : 'ADD_FILE_HISTORY',
payload : { fileHistory }
};
};
export const setPicture = (picture) =>
({
type : 'SET_PICTURE',
payload : { picture }
});
export const setPeerPicture = (peerName, picture) =>
({
type : 'SET_PEER_PICTURE',
payload : { peerName, picture }
});
export const loggedIn = () =>
({
type : 'LOGGED_IN'
});
export const setSelectedPeer = (selectedPeerName) => ({
type : 'SET_SELECTED_PEER',
payload : { selectedPeerName }
});

46
app/lib/store.js 100644
View File

@ -0,0 +1,46 @@
import {
applyMiddleware,
createStore,
compose
} from 'redux';
import thunk from 'redux-thunk';
import { createLogger } from 'redux-logger';
import reducers from './redux/reducers';
import roomClientMiddleware from './redux/roomClientMiddleware';
const reduxMiddlewares =
[
thunk,
roomClientMiddleware
];
if (process.env.NODE_ENV === 'development')
{
const reduxLogger = createLogger(
{
duration : true,
timestamp : false,
level : 'log',
logErrors : true
});
reduxMiddlewares.push(reduxLogger);
}
const composeEnhancers =
typeof window === 'object' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
// Specify extensions options like name, actionsBlacklist, actionsCreators, serialize...
}) : compose;
const enhancer = composeEnhancers(
applyMiddleware(...reduxMiddlewares)
// other store enhancers if any
);
export const store = createStore(
reducers,
undefined,
enhancer
);

View File

@ -43,3 +43,23 @@ export function getBrowserType()
return 'N/A'; return 'N/A';
} }
/**
* Create a function which will call the callback function
* after the given amount of milliseconds has passed since
* the last time the callback function was called.
*/
export const idle = (callback, delay) =>
{
let handle;
return () =>
{
if (handle)
{
clearTimeout(handle);
}
handle = setTimeout(callback, delay);
};
};

6357
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,65 +8,72 @@
"main": "lib/index.jsx", "main": "lib/index.jsx",
"dependencies": { "dependencies": {
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"classnames": "^2.2.5", "classnames": "^2.2.6",
"create-torrent": "^3.32.1",
"debug": "^3.1.0", "debug": "^3.1.0",
"domready": "^1.0.8", "domready": "^1.0.8",
"hark": "^1.1.6", "drag-drop": "^4.2.0",
"file-saver": "^1.3.8",
"fscreen": "^1.0.2",
"hark": "^1.2.2",
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"marked": "^0.3.17", "magnet-uri": "^5.2.3",
"marked": "^0.4.0",
"mediasoup-client": "^2.1.1", "mediasoup-client": "^2.1.1",
"prop-types": "^15.6.0", "prop-types": "^15.6.2",
"protoo-client": "^2.0.7", "protoo-client": "^3.0.0",
"random-string": "^0.2.0", "random-string": "^0.2.0",
"react": "^16.2.0", "react": "^16.4.1",
"react-clipboard.js": "^1.1.3", "react-copy-to-clipboard": "^5.0.1",
"react-dom": "^16.2.0", "react-dom": "^16.4.1",
"react-draggable": "^3.0.5", "react-draggable": "^3.0.5",
"react-dropdown": "^1.5.0", "react-dropdown": "^1.5.0",
"react-redux": "^5.0.6", "react-redux": "^5.0.7",
"react-spinner": "^0.2.7", "react-spinner": "^0.2.7",
"react-tooltip": "^3.4.0", "react-tooltip": "^3.6.1",
"react-transition-group": "^2.2.1", "react-transition-group": "^2.4.0",
"redux": "^3.7.2", "redux": "^4.0.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.3.0",
"resize-observer-polyfill": "^1.5.0",
"riek": "^1.1.0", "riek": "^1.1.0",
"url-parse": "^1.2.0" "url-parse": "^1.4.1",
"webtorrent": "^0.101.0"
}, },
"devDependencies": { "devDependencies": {
"babel-core": "^6.26.0", "babel-core": "^6.26.3",
"babel-plugin-transform-object-assign": "^6.22.0", "babel-eslint": "^8.2.6",
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-env": "^1.7.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1", "babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1", "babel-preset-react-app": "^3.1.2",
"babel-preset-stage-0": "^6.24.1", "babel-preset-stage-0": "^6.24.1",
"babelify": "^8.0.0", "babelify": "^8.0.0",
"browser-sync": "^2.23.6", "browser-sync": "^2.24.6",
"browserify": "^16.1.0", "browserify": "^16.2.2",
"del": "^3.0.0", "del": "^3.0.0",
"envify": "^4.1.0", "envify": "^4.1.0",
"eslint": "^4.17.0", "eslint": "^5.2.0",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.13.0",
"eslint-plugin-react": "^7.6.1", "eslint-plugin-react": "^7.10.0",
"gulp": "^4.0.0", "gulp": "^4.0.0",
"gulp-css-base64": "^1.3.4",
"gulp-eslint": "^4.0.2",
"gulp-change": "^1.0.0", "gulp-change": "^1.0.0",
"gulp-header": "^2.0.1", "gulp-css-base64": "^1.3.4",
"gulp-eslint": "^5.0.0",
"gulp-header": "^2.0.5",
"gulp-if": "^2.0.2", "gulp-if": "^2.0.2",
"gulp-plumber": "^1.2.0", "gulp-plumber": "^1.2.0",
"gulp-rename": "^1.2.2", "gulp-rename": "^1.4.0",
"gulp-stylus": "^2.7.0", "gulp-stylus": "^2.7.0",
"gulp-touch-cmd": "0.0.1", "gulp-touch-cmd": "0.0.1",
"gulp-uglify": "^3.0.0", "gulp-uglify": "^3.0.0",
"gulp-util": "^3.0.8", "gulp-util": "^3.0.8",
"lodash": "^4.17.10",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nib": "^1.1.2", "nib": "^1.1.2",
"supports-color": "^5.2.0", "supports-color": "^5.4.0",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0", "vinyl-source-stream": "^2.0.0",
"watchify": "^3.10.0" "watchify": "^3.11.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,4 @@
<svg fill="#FFF" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2z"/>
<path fill="none" d="M0 0h24v24H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFF">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFF">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,4 @@
<svg fill="#FFF" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@ -61,7 +61,7 @@
} }
[data-component='MessageList'] { [data-component='MessageList'] {
background-color: rgba(#fff, 0.9); background-color: rgba(#000, 0.1);
height: 91vmin; height: 91vmin;
overflow-y: scroll; overflow-y: scroll;
padding-top: 5px; padding-top: 5px;
@ -71,43 +71,35 @@
margin: 5px; margin: 5px;
display: flex; display: flex;
word-wrap: break-word; word-wrap: break-word;
color: rgba(#000, 1.0)
> .client { > .client {
background-color: rgba(#fff, 0.9);
border-radius: 5px;
padding: 6px;
max-width: 215px;
text-align: left;
margin-left: auto; margin-left: auto;
> .message-text {
font-size: 1.3vmin;
color: rgba(#000, 1.0);
}
> .message-time {
font-size: 1vmin;
color: rgba(#777, 1.0)
}
} }
> .response { > .client, > .response {
background-color: rgba(#fff, 0.9); background-color: rgba(#000, 0.1);
border-radius: 5px; border-radius: 5px;
padding: 6px;
max-width: 215px; max-width: 215px;
text-align: left; display: flex;
font-size: 1.3vmin; align-items: center;
padding: 6px;
> .message-text { > .message-avatar {
font-size: 1.3vmin; height: 2rem;
color: rgba(#000, 1.0); border-radius: 50%;
} }
> .message-time { > .message-content {
font-size: 1vmin; padding-left: 6px;
color: rgba(#777, 1.0);
> .message-text {
font-size: 1.3vmin;
}
> .message-time {
font-size: 1vmin;
opacity: 0.8;
}
} }
} }
} }
@ -116,7 +108,7 @@
[data-component='Sender'] { [data-component='Sender'] {
align-items: center; align-items: center;
display: flex; display: flex;
background-color: rgba(#fff, 0.9); background-color: rgba(#000, 0.1);
height: 6vmin; height: 6vmin;
padding: 0.5vmin; padding: 0.5vmin;
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
@ -125,8 +117,8 @@
width: 100%; width: 100%;
border: 0; border: 0;
border-radius: 5px; border-radius: 5px;
background-color: rgba(#fff, 0.9); background-color: rgba(#000, 0.1);
color: #000; color: #fff;
height: 30px; height: 30px;
padding-left: 10px; padding-left: 10px;
font-size: 1.4vmin; font-size: 1.4vmin;

View File

@ -0,0 +1,95 @@
[data-component='FileSharing'] {
display: flex;
flex-direction: column;
height: 100%;
> .sharing-toolbar {
> .share-file {
cursor: pointer;
width: 100%;
background: #252525;
border: 1px solid #151515;
padding: 1rem;
border-bottom: 5px solid #151515;
border-radius: 3px 3px 0 0;
&.disabled {
cursor: not-allowed;
}
}
}
> .shared-files {
flex-grow: 1;
overflow-y: scroll;
> .file-entry {
background-color: rgba(0,0,0,0.1);
border-radius: 5px;
width: 100%;
padding: 0.5rem;
display: flex;
margin-top: 0.5rem;
&:last-child {
margin-bottom: 1.5rem;
}
> .file-avatar {
height: 2rem;
border-radius: 50%;
}
> .file-content {
flex-grow: 1;
padding-left: 0.5rem;
> p:not(:first-child) {
margin-top: 0.5rem;
}
> .file-info {
display: flex;
padding-top: 0.5rem;
align-items: center;
> .button {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: #252525;
border: 1px solid #151515;
padding: 0.3rem;
border-bottom: 5px solid #151515;
border-radius: 3px 3px 0 0;
}
> p {
flex-grow: 1;
padding-left: 0.5rem;
}
}
}
}
}
}
#holding-overlay {
display: none;
}
.drag #holding-overlay {
display: flex;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: #FFF;
align-items: center;
justify-content: center;
font-size: 2rem;
z-index: 3000;
}

View File

@ -0,0 +1,78 @@
[data-component='Filmstrip'] {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
> .active-peer-container {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
> .active-peer {
width: 100%;
padding: 1vmin;
> [data-component='Peer'] {
border: 5px solid rgba(255, 255, 255, 0.15);
box-shadow: 0px 5px 12px 2px rgba(17, 17, 17, 0.5);
}
}
}
> .filmstrip {
display: flex;
background: rgba(0, 0, 0 , 0.5);
width: 100%;
overflow-x: auto;
height: 20vh;
align-items: center;
> .filmstrip-content {
margin: 0 auto;
display: flex;
height: 100%;
align-items: center;
> .film {
height: 18vh;
flex-shrink: 0;
padding-left: 1vh;
&:last-child {
padding-right: 1vh;
}
> .film-content {
height: 100%;
width: 100%;
border: 1px solid rgba(255,255,255,0.15);
> [data-component='Peer'] {
max-width: 18vh * (4 / 3);
cursor: pointer;
&.screen {
max-width: 18vh * (2 * 4 / 3);
border: 0;
}
}
}
&.active {
> .film-content {
border-color: #FFF;
}
}
&.selected {
> .film-content {
border-color: #377EFF;
}
}
}
}
}
}

View File

@ -4,11 +4,11 @@
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
z-index: 200; z-index: 2000;
> .controls { > .controls {
position: absolute; position: absolute;
z-index: 201; z-index: 2020;
right: 0; right: 0;
top: 0; top: 0;
display: flex; display: flex;
@ -53,7 +53,7 @@
.incompatible-video { .incompatible-video {
position: absolute; position: absolute;
z-index: 2 z-index: 2010;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;

View File

@ -6,17 +6,17 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background-color: rgba(#2a4b58, 0.9); background-image: url('/resources/images/background.svg');
background-image: url('/resources/images/buddy.svg'); background-attachment: fixed;
background-position: bottom; background-position: center;
background-size: auto 85%; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
> .info { > .info {
$backgroundTint = #000; $backgroundTint = #000;
position: absolute; position: absolute;
z-index: 5 z-index: 10;
top: 0.6vmin; top: 0.6vmin;
left: 0.6vmin; left: 0.6vmin;
bottom: 0; bottom: 0;

View File

@ -19,7 +19,7 @@
> .controls { > .controls {
position: absolute; position: absolute;
z-index: 10; z-index: 20;
right: 0; right: 0;
top: 0; top: 0;
display: flex; display: flex;
@ -27,6 +27,12 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: 0.4vmin; padding: 0.4vmin;
opacity: 0;
transition: opacity 0.3s;
&.visible {
opacity: 1;
}
> .button { > .button {
flex: 0 0 auto; flex: 0 0 auto;

View File

@ -1,15 +1,20 @@
[data-component='Notifications'] { [data-component='Notifications'] {
position: absolute; position: absolute;
z-index: 9999; z-index: 1010;
pointer-events: none; pointer-events: none;
top: 0; top: 45px;
right: 65px; right: 0;
bottom: 0; bottom: 0;
padding: 20px; padding: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-end; align-items: flex-end;
transition: right 0.3s;
&.toolarea-open {
right: 25%;
}
+desktop() { +desktop() {
padding: 10px; padding: 10px;

View File

@ -7,14 +7,75 @@
> .list-item { > .list-item {
padding: 0.5vmin; padding: 0.5vmin;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #CBCBCB;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
cursor: pointer;
&.me {
cursor: auto;
}
&.selected {
border-bottom-color: #377EFF;
}
} }
} }
} }
[data-component='ListPeer'] { [data-component='ListPeer'] {
display: flex;
align-items: center;
> .indicators {
left: 0;
top: 0;
display: flex;
flex-direction:; row;
justify-content: flex-start;
align-items: center;
padding: 0.4vmin;
transition: opacity 0.3s;
> .icon {
flex: 0 0 auto;
margin: 0.2vmin;
border-radius: 2px;
background-position: center;
background-size: 75%;
background-repeat: no-repeat;
background-color: rgba(#000, 0.5);
transition-property: opacity, background-color;
transition-duration: 0.15s;
+desktop() {
width: 24px;
height: 24px;
opacity: 0.85;
&:hover {
opacity: 1;
}
}
+mobile() {
width: 22px;
height: 22px;
}
&.on {
opacity: 1;
}
&.off {
opacity: 0.2;
}
&.raise-hand {
background-image: url('/resources/images/icon-hand-white.svg');
}
}
}
> .controls { > .controls {
float: right; float: right;
display: flex; display: flex;
@ -110,23 +171,16 @@
} }
> .avatar { > .avatar {
padding: 8px 16px; border-radius: 50%;
float: left; height: 2rem;
width: auto;
border: none;
display: block;
outline: 0;
border-radius: 50%;
vertical-align: middle;
} }
> .peer-info { > .peer-info {
font-size: 1.4vmin; font-size: 1.4vmin;
float: left; border: none;
width: auto; display: flex;
border: none; padding: 1vmin;
display: block; flex-grow: 1;
outline: 0; align-items: center;
padding: 0.6vmin;
} }
} }

View File

@ -22,6 +22,7 @@
> .view-container { > .view-container {
position: relative; position: relative;
flex-grow: 1;
&.webcam { &.webcam {
order: 2; order: 2;
@ -29,11 +30,62 @@
&.screen { &.screen {
order: 1; order: 1;
max-width: 50%;
} }
> .indicators {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-direction:; row;
justify-content: flex-start;
align-items: center;
padding: 0.4vmin;
transition: opacity 0.3s;
z-index: 20;
> .icon {
flex: 0 0 auto;
margin: 0.2vmin;
border-radius: 2px;
background-position: center;
background-size: 75%;
background-repeat: no-repeat;
background-color: rgba(#000, 0.5);
transition-property: opacity, background-color;
transition-duration: 0.15s;
+desktop() {
width: 24px;
height: 24px;
opacity: 0.85;
&:hover {
opacity: 1;
}
}
+mobile() {
width: 22px;
height: 22px;
}
&.on {
opacity: 1;
}
&.off {
opacity: 0.2;
}
&.raise-hand {
background-image: url('/resources/images/icon-hand-white.svg');
}
}
}
> .controls { > .controls {
position: absolute; position: absolute;
z-index: 10; z-index: 20;
right: 0; right: 0;
top: 0; top: 0;
display: flex; display: flex;
@ -41,6 +93,12 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: 0.4vmin; padding: 0.4vmin;
opacity: 0;
transition: opacity 0.3s;
&.visible {
opacity: 1;
}
> .button { > .button {
flex: 0 0 auto; flex: 0 0 auto;
@ -137,7 +195,7 @@
.incompatible-video { .incompatible-video {
position: absolute; position: absolute;
z-index: 2 z-index: 10;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;

View File

@ -16,7 +16,7 @@
$backgroundTint = #000; $backgroundTint = #000;
position: absolute; position: absolute;
z-index: 5 z-index: 10;
top: 0.6vmin; top: 0.6vmin;
left: 0.6vmin; left: 0.6vmin;
bottom: 0; bottom: 0;

View File

@ -7,7 +7,6 @@
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
transition: width 0.3s;
> .state { > .state {
position: fixed; position: fixed;
@ -95,7 +94,7 @@
> .room-link-wrapper { > .room-link-wrapper {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
z-index: 1; z-index: 10;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -142,7 +141,7 @@
> .me-container { > .me-container {
position: fixed; position: fixed;
z-index: 100; z-index: 110;
overflow: hidden; overflow: hidden;
box-shadow: 0px 5px 12px 2px rgba(#111, 0.5); box-shadow: 0px 5px 12px 2px rgba(#111, 0.5);
transition-property: border-color; transition-property: border-color;
@ -153,126 +152,44 @@
} }
+desktop() { +desktop() {
bottom: 20px; top: 20px;
left: 20px; left: 20px;
border: 1px solid rgba(#fff, 0.15); border: 1px solid rgba(#fff, 0.15);
} }
+mobile() { +mobile() {
bottom: 10px; top: 10px;
left: 10px; left: 10px;
border: 1px solid rgba(#fff, 0.25); border: 1px solid rgba(#fff, 0.25);
} }
} }
> .sidebar {
position: fixed;
z-index: 101;
top: calc(50% - 60px);
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
+desktop() {
left: 20px;
width: 36px;
}
+mobile() {
left: 10px;
width: 32px;
}
> .button {
flex: 0 0 auto;
margin: 4px 0;
background-position: center;
background-size: 75%;
background-repeat: no-repeat;
background-color: rgba(#fff, 0.3);
cursor: pointer;
transition-property: opacity, background-color;
transition-duration: 0.15s;
border-radius: 100%;
+desktop() {
height: 36px;
width: 36px;
}
+mobile() {
height: 32px;
width: 32px;
}
&.on {
background-color: rgba(#fff, 0.7);
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&.login {
&.off {
background-image: url('/resources/images/icon_login_white.svg');
}
}
&.settings {
&.off {
background-image: url('/resources/images/icon_settings_white.svg');
}
&.on {
background-image: url('/resources/images/icon_settings_black.svg');
}
}
&.screen {
&.on {
background-image: url('/resources/images/no-share-screen-black.svg');
}
&.off {
background-image: url('/resources/images/share-screen-white.svg');
}
&.unsupported {
background-image: url('/resources/images/no-share-screen-white.svg');
background-color: rgba(#d42241, 0.7);
}
&.need-extension {
background-image: url('/resources/images/share-screen-extension.svg');
}
}
&.raise-hand {
background-image: url('/resources/images/icon-hand-white.svg');
&.on {
background-image: url('/resources/images/icon-hand-black.svg');
}
}
&.leave-meeting {
background-image: url('/resources/images/leave-meeting.svg');
}
}
}
} }
> .toolarea-wrapper { > .toolarea-wrapper {
position: fixed; position: fixed;
width: 0;
top: 0; top: 0;
right: 0; right: 0;
width: 20%;
height: 100%; height: 100%;
background-color: #FFF; background-color: rgba(50, 50, 50, 0.9);
transition: width 0.3s; transition: width 0.3s;
z-index: 1000;
&.open {
width: 25%;
}
}
}
.room-controls {
visibility: hidden;
animation: fade-out 0.3s;
opacity: 0;
&.visible {
visibility: visible;
opacity: 1;
animation: fade-in 0.3s;
} }
} }
@ -329,7 +246,7 @@
position: absolute; position: absolute;
top: 100%; top: 100%;
width: 100%; width: 100%;
z-index: 1000; z-index: 120;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }

View File

@ -16,7 +16,7 @@
$backgroundTint = #000; $backgroundTint = #000;
position: absolute; position: absolute;
z-index: 5 z-index: 10;
top: 0.6vmin; top: 0.6vmin;
left: 0.6vmin; left: 0.6vmin;
bottom: 0; bottom: 0;

View File

@ -0,0 +1,116 @@
[data-component='Sidebar'] {
position: fixed;
z-index: 500;
top: calc(50% - 60px);
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
+desktop() {
left: 20px;
width: 36px;
}
+mobile() {
left: 10px;
width: 32px;
}
> .button {
flex: 0 0 auto;
margin: 4px 0;
background-position: center;
background-size: 75%;
background-repeat: no-repeat;
background-color: rgba(#fff, 0.3);
cursor: pointer;
transition-property: opacity, background-color;
transition-duration: 0.15s;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
+desktop() {
height: 36px;
width: 36px;
}
+mobile() {
height: 32px;
width: 32px;
}
&.on {
background-color: rgba(#fff, 0.7);
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&.login {
&.off {
background-image: url('/resources/images/icon_login_white.svg');
}
}
&.logout > img {
height: 65%;
max-width: 65%;
border-radius: 50%;
}
&.settings {
&.off {
background-image: url('/resources/images/icon_settings_white.svg');
}
&.on {
background-image: url('/resources/images/icon_settings_black.svg');
}
}
&.fullscreen {
background-image: url('/resources/images/icon_fullscreen_white.svg');
&.on {
background-image: url('/resources/images/icon_fullscreen_exit_white.svg');
}
}
&.screen {
&.on {
background-image: url('/resources/images/no-share-screen-black.svg');
}
&.off {
background-image: url('/resources/images/share-screen-white.svg');
}
&.unsupported {
background-image: url('/resources/images/no-share-screen-white.svg');
background-color: rgba(#d42241, 0.7);
}
&.need-extension {
background-image: url('/resources/images/share-screen-extension.svg');
}
}
&.raise-hand {
background-image: url('/resources/images/icon-hand-white.svg');
&.on {
background-image: url('/resources/images/icon-hand-black.svg');
}
}
&.leave-meeting {
background-image: url('/resources/images/leave-meeting.svg');
}
}
}

View File

@ -1,14 +1,19 @@
[data-component='ToolAreaButton'] { [data-component='ToolAreaButton'] {
position: absolute; position: absolute;
z-index: 101; z-index: 1000;
top: 20px; right: 0;
right: 20px;
height: 36px; height: 36px;
width: 36px; width: 36px;
padding: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transition: right 0.3s;
&.on {
right: 25%;
}
> .button { > .button {
flex: 0 0 auto; flex: 0 0 auto;
@ -49,11 +54,31 @@
} }
} }
} }
> .badge {
border-radius: 50%;
font-size: 1rem;
background: #b12525;
color: #fff;
text-align: center;
margin-top: -8px;
line-height: 1rem;
margin-right: -8px;
position: absolute;
padding: 0.2rem 0.4rem;
top: 0;
right: 0;
&.long {
border-radius: 25% / 50%;
}
}
} }
[data-component='ToolArea'] { [data-component='ToolArea'] {
width: 100%; width: 100%;
height: 100%; height: 100%;
color: #fff;
> .tabs { > .tabs {
display: flex; display: flex;
@ -63,15 +88,26 @@
> label { > label {
order: 1; order: 1;
display: block; display: block;
padding: 1vmin 0 1vmin 0; padding: 1vmin 0 0.8vmin 0;
cursor: pointer; cursor: pointer;
background: rgba(#000, 0.3); background: rgba(0,0,0,0.3);
font-weight: bold; font-weight: bold;
transition: background ease 0.2s; transition: background ease 0.2s;
text-align: center; text-align: center;
width: 33.33%; width: 25%;
font-size: 1.3vmin; font-size: 1.3vmin;
height: 3vmin; height: 3vmin;
> .badge {
padding: 0.1vmin 1vmin;
text-align: center;
font-weight: 300;
font-size: 1.2vmin;
color: #fff;
background-color: #b12525;
border-radius: 2px;
margin-left: 1vmin;
}
} }
> .tab { > .tab {
@ -81,7 +117,7 @@
height: 100%; height: 100%;
display: none; display: none;
padding: 1vmin; padding: 1vmin;
background: #fff; background: rgba(0,0,0,0.1);
} }
> input[type="radio"] { > input[type="radio"] {
@ -89,11 +125,12 @@
} }
> input[type="radio"]:checked + label { > input[type="radio"]:checked + label {
background: #fff; background: rgba(0,0,0,0.1);
} }
> input[type="radio"]:checked + label + .tab { > input[type="radio"]:checked + label + .tab {
display: block; display: block;
background: rgba(0,0,0,0.1);
} }
} }
} }

View File

@ -5,6 +5,7 @@ global-reset();
@import './mixins'; @import './mixins';
@import './fonts'; @import './fonts';
@import './reset'; @import './reset';
@import './keyframes';
html { html {
height: 100%; height: 100%;
@ -28,28 +29,32 @@ html {
body { body {
height: 100%; height: 100%;
overflow-x: hidden;
} }
#multiparty-meeting { #multiparty-meeting {
height: 100%; height: 100%;
width: 100%; width: 100%;
// Components
@import './components/Room';
@import './components/Me';
@import './components/Peers';
@import './components/Peer';
@import './components/PeerView';
@import './components/ScreenView';
@import './components/Notifications';
@import './components/Chat';
@import './components/Settings';
@import './components/ToolArea';
@import './components/ParticipantList';
@import './components/FullScreenView';
@import './components/FullView';
} }
// Components
@import './components/Room';
@import './components/Sidebar';
@import './components/Me';
@import './components/Peers';
@import './components/Peer';
@import './components/PeerView';
@import './components/ScreenView';
@import './components/Notifications';
@import './components/Chat';
@import './components/Settings';
@import './components/ToolArea';
@import './components/ParticipantList';
@import './components/FullScreenView';
@import './components/FullView';
@import './components/Filmstrip';
@import './components/FileSharing';
// Hack to detect in JS the current media query // Hack to detect in JS the current media query
#multiparty-meeting-media-query-detector { #multiparty-meeting-media-query-detector {
position: absolute; position: absolute;

View File

@ -0,0 +1,21 @@
@keyframes fade-in {
from {
opacity: 0;
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes fade-out {
from {
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}

View File

@ -3,15 +3,9 @@ module.exports =
// oAuth2 conf // oAuth2 conf
oauth2 : oauth2 :
{ {
client_id : '', clientID : '',
client_secret : '', clientSecret : '',
providerID : '', callbackURL : 'https://mYDomainName:port/auth-callback'
redirect_uri : 'https://mYDomainName:port/auth-callback',
authorization_endpoint : '',
userinfo_endpoint : '',
token_endpoint : '',
scopes : { request : [ 'openid', 'userid','profile'] },
response_type : 'code'
}, },
// Listening hostname for `gulp live|open`. // Listening hostname for `gulp live|open`.
domain : 'localhost', domain : 'localhost',
@ -22,6 +16,15 @@ module.exports =
}, },
// Listening port for https server. // Listening port for https server.
listeningPort : 3443, listeningPort : 3443,
turnServers : [
{
urls : [
'turn:example.com:443?transport=tcp'
],
username : 'example',
credential : 'example'
}
],
mediasoup : mediasoup :
{ {
// mediasoup Server settings. // mediasoup Server settings.

View File

@ -15,20 +15,30 @@ const gulp = require('gulp');
const plumber = require('gulp-plumber'); const plumber = require('gulp-plumber');
const eslint = require('gulp-eslint'); const eslint = require('gulp-eslint');
const LINTING_FILES =
[
'gulpfile.js',
'server.js',
'config.example.js',
'lib/**/*.js'
];
gulp.task('lint', () => gulp.task('lint', () =>
{ {
const src =
[
'gulpfile.js',
'server.js',
'config.example.js',
'lib/**/*.js'
];
return gulp.src(src) return gulp.src(LINTING_FILES)
.pipe(plumber()) .pipe(plumber())
.pipe(eslint()) .pipe(eslint())
.pipe(eslint.format()); .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('default', gulp.series('lint')); gulp.task('default', gulp.series('lint'));

View File

@ -2,6 +2,7 @@
const EventEmitter = require('events').EventEmitter; const EventEmitter = require('events').EventEmitter;
const protooServer = require('protoo-server'); const protooServer = require('protoo-server');
const WebTorrent = require('webtorrent-hybrid');
const Logger = require('./Logger'); const Logger = require('./Logger');
const config = require('../config'); const config = require('../config');
@ -11,6 +12,14 @@ const BITRATE_FACTOR = 0.75;
const logger = new Logger('Room'); const logger = new Logger('Room');
const torrentClient = new WebTorrent({
tracker : {
rtcConfig : {
iceServers : config.turnServers
}
}
});
class Room extends EventEmitter class Room extends EventEmitter
{ {
constructor(roomId, mediaServer) constructor(roomId, mediaServer)
@ -28,6 +37,8 @@ class Room extends EventEmitter
this._chatHistory = []; this._chatHistory = [];
this._fileHistory = [];
try try
{ {
// Protoo Room instance. // Protoo Room instance.
@ -228,6 +239,18 @@ class Room extends EventEmitter
break; break;
} }
case 'change-profile-picture':
{
accept();
this._protooRoom.spread('profile-picture-changed', {
peerName : protooPeer.id,
picture : request.data.picture
}, [ protooPeer ]);
break;
}
case 'chat-message': case 'chat-message':
{ {
accept(); accept();
@ -260,6 +283,37 @@ class Room extends EventEmitter
break; break;
} }
case 'send-file':
{
accept();
const fileData = request.data.file;
this._fileHistory.push(fileData);
if (!torrentClient.get(fileData.file.magnet))
{
torrentClient.add(fileData.file.magnet);
}
this._protooRoom.spread('file-receive', {
file : fileData
}, [ protooPeer ]);
break;
}
case 'file-history':
{
accept();
protooPeer.send('file-history-receive', {
fileHistory : this._fileHistory
});
break;
}
case 'raisehand-message': case 'raisehand-message':
{ {
accept(); accept();
@ -267,12 +321,12 @@ class Room extends EventEmitter
const { raiseHandState } = request.data; const { raiseHandState } = request.data;
const { mediaPeer } = protooPeer.data; const { mediaPeer } = protooPeer.data;
mediaPeer.appData.raiseHand = request.data.raiseHandState; mediaPeer.appData.raiseHandState = request.data.raiseHandState;
// Spread to others via protoo. // Spread to others via protoo.
this._protooRoom.spread( this._protooRoom.spread(
'raisehand-message', 'raisehand-message',
{ {
peerName : protooPeer.id, peerName : protooPeer.id,
raiseHandState : raiseHandState raiseHandState : raiseHandState
}, },
[ protooPeer ]); [ protooPeer ]);

321
server/mediasoup.js 100644
View File

@ -0,0 +1,321 @@
const mediasoup = require('mediasoup');
const readline = require('readline');
const colors = require('colors/safe');
const repl = require('repl');
const homer = require('./lib/homer');
const config = require('./config');
// mediasoup server.
const mediaServer = mediasoup.Server(
{
numWorkers : 1,
logLevel : config.mediasoup.logLevel,
logTags : config.mediasoup.logTags,
rtcIPv4 : config.mediasoup.rtcIPv4,
rtcIPv6 : config.mediasoup.rtcIPv6,
rtcAnnouncedIPv4 : config.mediasoup.rtcAnnouncedIPv4,
rtcAnnouncedIPv6 : config.mediasoup.rtcAnnouncedIPv6,
rtcMinPort : config.mediasoup.rtcMinPort,
rtcMaxPort : config.mediasoup.rtcMaxPort
});
// Do Homer stuff.
if (process.env.MEDIASOUP_HOMER_OUTPUT)
homer(mediaServer);
global.SERVER = mediaServer;
mediaServer.on('newroom', (room) =>
{
global.ROOM = room;
room.on('newpeer', (peer) =>
{
global.PEER = peer;
if (peer.consumers.length > 0)
global.CONSUMER = peer.consumers[peer.consumers.length - 1];
peer.on('newtransport', (transport) =>
{
global.TRANSPORT = transport;
});
peer.on('newproducer', (producer) =>
{
global.PRODUCER = producer;
});
peer.on('newconsumer', (consumer) =>
{
global.CONSUMER = consumer;
});
});
});
// Listen for keyboard input.
let cmd;
let terminal;
openCommandConsole();
function openCommandConsole()
{
stdinLog('[opening Readline Command Console...]');
closeCommandConsole();
closeTerminal();
cmd = readline.createInterface(
{
input : process.stdin,
output : process.stdout
});
cmd.on('SIGINT', () =>
{
process.exit();
});
readStdin();
function readStdin()
{
cmd.question('cmd> ', (answer) =>
{
switch (answer)
{
case '':
{
readStdin();
break;
}
case 'h':
case 'help':
{
stdinLog('');
stdinLog('available commands:');
stdinLog('- h, help : show this message');
stdinLog('- sd, serverdump : execute server.dump()');
stdinLog('- rd, roomdump : execute room.dump() for the latest created mediasoup Room');
stdinLog('- pd, peerdump : execute peer.dump() for the latest created mediasoup Peer');
stdinLog('- td, transportdump : execute transport.dump() for the latest created mediasoup Transport');
stdinLog('- prd, producerdump : execute producer.dump() for the latest created mediasoup Producer');
stdinLog('- cd, consumerdump : execute consumer.dump() for the latest created mediasoup Consumer');
stdinLog('- t, terminal : open REPL Terminal');
stdinLog('');
readStdin();
break;
}
case 'sd':
case 'serverdump':
{
mediaServer.dump()
.then((data) =>
{
stdinLog(`server.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`mediaServer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'rd':
case 'roomdump':
{
if (!global.ROOM)
{
readStdin();
break;
}
global.ROOM.dump()
.then((data) =>
{
stdinLog(`room.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`room.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'pd':
case 'peerdump':
{
if (!global.PEER)
{
readStdin();
break;
}
global.PEER.dump()
.then((data) =>
{
stdinLog(`peer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`peer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'td':
case 'transportdump':
{
if (!global.TRANSPORT)
{
readStdin();
break;
}
global.TRANSPORT.dump()
.then((data) =>
{
stdinLog(`transport.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`transport.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'prd':
case 'producerdump':
{
if (!global.PRODUCER)
{
readStdin();
break;
}
global.PRODUCER.dump()
.then((data) =>
{
stdinLog(`producer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`producer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'cd':
case 'consumerdump':
{
if (!global.CONSUMER)
{
readStdin();
break;
}
global.CONSUMER.dump()
.then((data) =>
{
stdinLog(`consumer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`consumer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 't':
case 'terminal':
{
openTerminal();
break;
}
default:
{
stdinError(`unknown command: ${answer}`);
stdinLog('press \'h\' or \'help\' to get the list of available commands');
readStdin();
}
}
});
}
}
function openTerminal()
{
stdinLog('[opening REPL Terminal...]');
closeCommandConsole();
closeTerminal();
terminal = repl.start(
{
prompt : 'terminal> ',
useColors : true,
useGlobal : true,
ignoreUndefined : false
});
terminal.on('exit', () => openCommandConsole());
}
function closeCommandConsole()
{
if (cmd)
{
cmd.close();
cmd = undefined;
}
}
function closeTerminal()
{
if (terminal)
{
terminal.removeAllListeners('exit');
terminal.close();
terminal = undefined;
}
}
function stdinLog(msg)
{
// eslint-disable-next-line no-console
console.log(colors.green(msg));
}
function stdinError(msg)
{
// eslint-disable-next-line no-console
console.error(colors.red.bold('ERROR: ') + colors.red(msg));
}
module.exports = mediaServer;

7539
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,11 +7,14 @@
"license": "MIT", "license": "MIT",
"main": "lib/index.js", "main": "lib/index.js",
"dependencies": { "dependencies": {
"base-64": "^0.1.0",
"colors": "^1.1.2", "colors": "^1.1.2",
"debug": "^3.1.0", "debug": "^3.1.0",
"express": "^4.16.2", "express": "^4.16.3",
"mediasoup": "^2.1.0", "mediasoup": "^2.1.0",
"protoo-server": "^2.0.7" "passport-dataporten": "^1.3.0",
"protoo-server": "^2.0.7",
"webtorrent-hybrid": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"gulp": "^4.0.0", "gulp": "^4.0.0",

View File

@ -1,196 +0,0 @@
'use strict';
const EventEmitter = require( 'events' );
const eventEmitter = new EventEmitter();
const path = require('path');
const url = require('url');
const httpHelpers = require('./http-helpers');
const fs = require('fs');
const config = require('./config');
const utils = require('./util');
const querystring = require('querystring');
const https = require('https')
const Logger = require('./lib/Logger');
const logger = new Logger();
let authRequests = {}; // ongoing auth requests :
/*
{
state:
{
peerName:'peerName'
code:'oauth2 code',
roomId: 'romid',
}
}
*/
const actions = {
'GET': function(req, res) {
var parsedUrl = url.parse(req.url,true);
if ( parsedUrl.pathname === '/auth-callback' )
{
if ( typeof(authRequests[parsedUrl.query.state]) != 'undefined' )
{
console.log('got authorization code for access token: ',parsedUrl.query,authRequests[parsedUrl.query.state]);
const auth = "Basic " + new Buffer(config.oauth2.client_id + ":" + config.oauth2.client_secret).toString("base64");
const postUrl = url.parse(config.oauth2.token_endpoint);
let postData = querystring.stringify({
"grant_type":"authorization_code",
"code":parsedUrl.query.code,
"redirect_uri":config.oauth2.redirect_uri
});
let request = https.request( {
host : postUrl.hostname,
path : postUrl.pathname,
port : postUrl.port,
method : 'POST',
headers :
{
'Content-Type' : 'application/x-www-form-urlencoded',
'Authorization' : auth,
'Content-Length': Buffer.byteLength(postData)
}
}, function(res)
{
res.setEncoding("utf8");
let body = "";
res.on("data", data => {
body += data;
});
res.on("end", () => {
if ( res.statusCode == 200 )
{
console.log('We\'ve got an access token!', body);
body = JSON.parse(body);
authRequests[parsedUrl.query.state].access_token =
body.access_token;
const auth = "Bearer " + body.access_token;
const getUrl = url.parse(config.oauth2.userinfo_endpoint);
let request = https.request( {
host : getUrl.hostname,
path : getUrl.pathname,
port : getUrl.port,
method : 'GET',
headers :
{
'Authorization' : auth,
}
}, function(res)
{
res.setEncoding("utf8");
let body = '';
res.on("data", data => {
body += data;
});
res.on("end", () => {
// we don't need this any longer:
delete authRequests[parsedUrl.query.state].access_token;
body = JSON.parse(body);
console.log(body);
if ( res.statusCode == 200 )
{
authRequests[parsedUrl.query.state].verified = true;
if ( typeof(body.sub) != 'undefined')
{
authRequests[parsedUrl.query.state].sub = body.sub;
}
if ( typeof(body.name) != 'undefined')
{
authRequests[parsedUrl.query.state].name = body.name;
}
if ( typeof(body.picture) != 'undefined')
{
authRequests[parsedUrl.query.state].picture = body.picture;
}
} else {
{
authRequests[parsedUrl.query.state].verified = false;
}
}
eventEmitter.emit('auth',
authRequests[parsedUrl.query.state]);
delete authRequests[parsedUrl.query.state];
});
});
request.write(' ');
request.end;
}
else
{
console.log('access_token denied',body);
authRequests[parsedUrl.query.state].verified = false;
delete authRequests[parsedUrl.query.state].access_token;
eventEmitter.emit('auth',
authRequests[parsedUrl.query.state]);
}
});
});
request.write(postData);
request.end;
}
else
{
logger.warn('Got authorization_code for unseen state:', parsedUrl)
}
}
else if (parsedUrl.pathname === '/login') {
const state = utils.random(10);
httpHelpers.redirector(res, config.oauth2.authorization_endpoint
+ '?client_id=' + config.oauth2.client_id
+ '&redirect_uri=' + config.oauth2.redirect_uri
+ '&state=' + state
+ '&scopes=' + config.oauth2.scopes.request.join('+')
+ '&response_type=' + config.oauth2.response_type);
authRequests[state] =
{
'roomId' : parsedUrl.query.roomId,
'peerName' : parsedUrl.query.peerName
};
console.log('Started authorization process: ', parsedUrl.query);
}
else
{
console.log('requested url:', parsedUrl.pathname);
var resolvedBase = path.resolve('./public');
var safeSuffix = path.normalize(req.url).replace(/^(\.\.[\/\\])+/, '');
var fileLoc = path.join(resolvedBase, safeSuffix);
var headers = {};
var stream = fs.createReadStream(fileLoc);
// Handle non-existent file -> delivering index.html
stream.on('error', function(error) {
stream = fs.createReadStream(path.resolve('./public/index.html'));
res.statusCode = 200;
stream.pipe(res);
});
// File exists, stream it to user
if (parsedUrl.pathname.indexOf('svg') === parsedUrl.pathname.length -3) {headers = {'Content-Type': 'image/svg+xml'}};
res.writeHead(200, headers);
stream.pipe(res);
}
},
'POST': function(req, res) {
httpHelpers.prepareResponse(req, function(data) {
// Do something with the data that was just collected by the helper
// e.g., validate and save to db
// either redirect or respond
// should be based on result of the operation performed in response to the POST request intent
// e.g., if user wants to save, and save fails, throw error
httpHelpers.redirector(res, /* redirect path , optional status code - defaults to 302 */);
});
}
};
module.exports = eventEmitter;
module.exports.handleRequest = function(req, res) {
var action = actions[req.method];
action ? action(req, res) : httpHelpers.send404(res);
};

View File

@ -5,6 +5,16 @@
process.title = 'multiparty-meeting-server'; process.title = 'multiparty-meeting-server';
const config = require('./config'); const config = require('./config');
const fs = require('fs');
const https = require('https');
const express = require('express');
const url = require('url');
const protooServer = require('protoo-server');
const Logger = require('./lib/Logger');
const Room = require('./lib/Room');
const Dataporten = require('passport-dataporten');
const utils = require('./util');
const base64 = require('base-64');
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log('- process.env.DEBUG:', process.env.DEBUG); console.log('- process.env.DEBUG:', process.env.DEBUG);
@ -12,100 +22,83 @@ console.log('- config.mediasoup.logLevel:', config.mediasoup.logLevel);
console.log('- config.mediasoup.logTags:', config.mediasoup.logTags); console.log('- config.mediasoup.logTags:', config.mediasoup.logTags);
/* eslint-enable no-console */ /* eslint-enable no-console */
const fs = require('fs'); // Start the mediasoup server.
const https = require('https'); const mediaServer = require('./mediasoup');
const router = require('./router');
const url = require('url');
const path = require('path');
const protooServer = require('protoo-server');
const mediasoup = require('mediasoup');
const readline = require('readline');
const colors = require('colors/safe');
const repl = require('repl');
const Logger = require('./lib/Logger');
const Room = require('./lib/Room');
const homer = require('./lib/homer');
const logger = new Logger(); const logger = new Logger();
// Map of Room instances indexed by roomId. // Map of Room instances indexed by roomId.
const rooms = new Map(); const rooms = new Map();
// mediasoup server. // TLS server configuration.
const mediaServer = mediasoup.Server(
{
numWorkers : 1,
logLevel : config.mediasoup.logLevel,
logTags : config.mediasoup.logTags,
rtcIPv4 : config.mediasoup.rtcIPv4,
rtcIPv6 : config.mediasoup.rtcIPv6,
rtcAnnouncedIPv4 : config.mediasoup.rtcAnnouncedIPv4,
rtcAnnouncedIPv6 : config.mediasoup.rtcAnnouncedIPv6,
rtcMinPort : config.mediasoup.rtcMinPort,
rtcMaxPort : config.mediasoup.rtcMaxPort
});
// Do Homer stuff.
if (process.env.MEDIASOUP_HOMER_OUTPUT)
homer(mediaServer);
global.SERVER = mediaServer;
mediaServer.on('newroom', (room) =>
{
global.ROOM = room;
room.on('newpeer', (peer) =>
{
global.PEER = peer;
if (peer.consumers.length > 0)
global.CONSUMER = peer.consumers[peer.consumers.length - 1];
peer.on('newtransport', (transport) =>
{
global.TRANSPORT = transport;
});
peer.on('newproducer', (producer) =>
{
global.PRODUCER = producer;
});
peer.on('newconsumer', (consumer) =>
{
global.CONSUMER = consumer;
});
});
});
// HTTPS server
const tls = const tls =
{ {
cert : fs.readFileSync(config.tls.cert), cert : fs.readFileSync(config.tls.cert),
key : fs.readFileSync(config.tls.key) key : fs.readFileSync(config.tls.key)
}; };
const httpsServer = https.createServer(tls, router.handleRequest); const app = express();
httpsServer.listen(config.listeningPort, '0.0.0.0', () =>
const dataporten = new Dataporten.Setup(config.oauth2);
app.use(dataporten.passport.initialize());
app.use(dataporten.passport.session());
app.get('/login', (req, res, next) =>
{ {
logger.info('Server running, port: ',config.listeningPort); dataporten.passport.authenticate('dataporten', {
state : base64.encode(JSON.stringify({
roomId : req.query.roomId,
peerName : req.query.peerName,
code : utils.random(10)
}))
})(req, res, next);
}); });
router.on('auth',function(event){ dataporten.setupLogout(app, '/logout');
console.log('router: Got an event: ',event)
if ( rooms.has(event.roomId) )
{
const room = rooms.get(event.roomId)._protooRoom;
if ( room.hasPeer(event.peerName) )
{
const peer = room.getPeer(event.peerName);
peer.send('auth', event)
}
}
})
// Protoo WebSocket server listens to same webserver so everythink is available app.get(
'/auth-callback',
dataporten.passport.authenticate('dataporten', { failureRedirect: '/login' }),
(req, res) =>
{
const state = JSON.parse(base64.decode(req.query.state));
if (rooms.has(state.roomId))
{
const room = rooms.get(state.roomId)._protooRoom;
if (room.hasPeer(state.peerName))
{
const peer = room.getPeer(state.peerName);
peer.send('auth', {
name : req.user.data.displayName,
picture : req.user.data.photos[0]
});
}
}
res.send('');
}
);
// Serve all files in the public folder as static files.
app.use(express.static('public'));
app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`));
const httpsServer = https.createServer(tls, app);
httpsServer.listen(config.listeningPort, '0.0.0.0', () =>
{
logger.info('Server running on port: ', config.listeningPort);
});
// Protoo WebSocket server listens to same webserver so everything is available
// via same port // via same port
const webSocketServer = new protooServer.WebSocketServer(httpsServer, const webSocketServer = new protooServer.WebSocketServer(httpsServer,
{ {
@ -179,268 +172,3 @@ webSocketServer.on('connectionrequest', (info, accept, reject) =>
room.handleConnection(peerName, transport); room.handleConnection(peerName, transport);
}); });
// Listen for keyboard input.
let cmd;
let terminal;
openCommandConsole();
function openCommandConsole()
{
stdinLog('[opening Readline Command Console...]');
closeCommandConsole();
closeTerminal();
cmd = readline.createInterface(
{
input : process.stdin,
output : process.stdout
});
cmd.on('SIGINT', () =>
{
process.exit();
});
readStdin();
function readStdin()
{
cmd.question('cmd> ', (answer) =>
{
switch (answer)
{
case '':
{
readStdin();
break;
}
case 'h':
case 'help':
{
stdinLog('');
stdinLog('available commands:');
stdinLog('- h, help : show this message');
stdinLog('- sd, serverdump : execute server.dump()');
stdinLog('- rd, roomdump : execute room.dump() for the latest created mediasoup Room');
stdinLog('- pd, peerdump : execute peer.dump() for the latest created mediasoup Peer');
stdinLog('- td, transportdump : execute transport.dump() for the latest created mediasoup Transport');
stdinLog('- prd, producerdump : execute producer.dump() for the latest created mediasoup Producer');
stdinLog('- cd, consumerdump : execute consumer.dump() for the latest created mediasoup Consumer');
stdinLog('- t, terminal : open REPL Terminal');
stdinLog('');
readStdin();
break;
}
case 'sd':
case 'serverdump':
{
mediaServer.dump()
.then((data) =>
{
stdinLog(`server.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`mediaServer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'rd':
case 'roomdump':
{
if (!global.ROOM)
{
readStdin();
break;
}
global.ROOM.dump()
.then((data) =>
{
stdinLog(`room.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`room.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'pd':
case 'peerdump':
{
if (!global.PEER)
{
readStdin();
break;
}
global.PEER.dump()
.then((data) =>
{
stdinLog(`peer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`peer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'td':
case 'transportdump':
{
if (!global.TRANSPORT)
{
readStdin();
break;
}
global.TRANSPORT.dump()
.then((data) =>
{
stdinLog(`transport.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`transport.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'prd':
case 'producerdump':
{
if (!global.PRODUCER)
{
readStdin();
break;
}
global.PRODUCER.dump()
.then((data) =>
{
stdinLog(`producer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`producer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 'cd':
case 'consumerdump':
{
if (!global.CONSUMER)
{
readStdin();
break;
}
global.CONSUMER.dump()
.then((data) =>
{
stdinLog(`consumer.dump() succeeded:\n${JSON.stringify(data, null, ' ')}`);
readStdin();
})
.catch((error) =>
{
stdinError(`consumer.dump() failed: ${error}`);
readStdin();
});
break;
}
case 't':
case 'terminal':
{
openTerminal();
break;
}
default:
{
stdinError(`unknown command: ${answer}`);
stdinLog('press \'h\' or \'help\' to get the list of available commands');
readStdin();
}
}
});
}
}
function openTerminal()
{
stdinLog('[opening REPL Terminal...]');
closeCommandConsole();
closeTerminal();
terminal = repl.start(
{
prompt : 'terminal> ',
useColors : true,
useGlobal : true,
ignoreUndefined : false
});
terminal.on('exit', () => openCommandConsole());
}
function closeCommandConsole()
{
if (cmd)
{
cmd.close();
cmd = undefined;
}
}
function closeTerminal()
{
if (terminal)
{
terminal.removeAllListeners('exit');
terminal.close();
terminal = undefined;
}
}
function stdinLog(msg)
{
// eslint-disable-next-line no-console
console.log(colors.green(msg));
}
function stdinError(msg)
{
// eslint-disable-next-line no-console
console.error(colors.red.bold('ERROR: ') + colors.red(msg));
}