Merge branch 'feat-lobby' into develop

master
Håvar Aambø Fosstveit 2019-11-04 23:40:47 +01:00
commit 51e4d6664d
49 changed files with 3570 additions and 1911 deletions

View File

@ -110,3 +110,8 @@ This started as a fork of the [work](https://github.com/versatica/mediasoup-demo
## License ## License
MIT MIT
Contributions to this work were made on behalf of the GÉANT project, a project that has received funding from the European Unions Horizon 2020 research and innovation programme under Grant Agreement No. 731122 (GN4-2). On behalf of GÉANT project, GÉANT Association is the sole owner of the copyright in all material which was developed by a member of the GÉANT project.
GÉANT Vereniging (Association) is registered with the Chamber of Commerce in Amsterdam with registration number 40535155 and operates in the UK as a branch of GÉANT Vereniging. Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK.

333
app/.eslintrc.json 100644
View File

@ -0,0 +1,333 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"plugins": [
"import",
"react"
],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"react-app"
],
"settings": {
"react": {
"pragma": "React",
"version": "16"
}
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true,
"jsx": true
}
},
"rules": {
"array-bracket-spacing": [
2,
"always",
{
"objectsInArrays": true,
"arraysInArrays": true
}
],
"arrow-parens": [
2,
"always"
],
"arrow-spacing": 2,
"block-spacing": [
2,
"always"
],
"brace-style": [
2,
"allman",
{
"allowSingleLine": true
}
],
"camelcase": 2,
"comma-dangle": 2,
"comma-spacing": [
2,
{
"before": false,
"after": true
}
],
"comma-style": 2,
"computed-property-spacing": 2,
"constructor-super": 2,
"func-call-spacing": 2,
"generator-star-spacing": 2,
"guard-for-in": 2,
"indent": [
2,
"tab",
{
"SwitchCase": 1
}
],
"key-spacing": [
2,
{
"singleLine": {
"beforeColon": false,
"afterColon": true
},
"multiLine": {
"beforeColon": true,
"afterColon": true,
"align": "colon"
}
}
],
"keyword-spacing": 2,
"linebreak-style": [
2,
"unix"
],
"lines-around-comment": [
2,
{
"allowBlockStart": true,
"allowObjectStart": true,
"beforeBlockComment": true,
"beforeLineComment": false
}
],
"max-len": [
2,
90,
{
"tabWidth": 2,
"comments": 110,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"newline-after-var": 2,
"newline-before-return": 2,
"newline-per-chained-call": 2,
"no-alert": 2,
"no-caller": 2,
"no-case-declarations": 2,
"no-catch-shadow": 2,
"no-class-assign": 2,
"no-confusing-arrow": [
"error",
{
"allowParens": true
}
],
"no-console": 2,
"no-const-assign": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-div-regex": 2,
"no-empty": [
2,
{
"allowEmptyCatch": true
}
],
"no-empty-pattern": 2,
"no-else-return": 0,
"no-eval": 2,
"no-extend-native": 2,
"no-ex-assign": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-label": 2,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-func-assign": 2,
"no-global-assign": 2,
"no-implicit-coercion": 2,
"no-implicit-globals": 2,
"no-inner-declarations": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-lonely-if": 2,
"no-mixed-operators": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [
2,
{
"max": 1,
"maxEOF": 0,
"maxBOF": 0
}
],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-prototype-builtins": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-imports": 2,
"no-return-assign": 2,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-undef": 2,
"no-unexpected-multiline": 2,
"no-unmodified-loop-condition": 2,
"no-unreachable": 2,
"no-unused-vars": [
1,
{
"vars": "all",
"args": "after-used"
}
],
"no-use-before-define": [
2,
{
"functions": false
}
],
"no-useless-call": 2,
"no-useless-computed-key": 2,
"no-useless-concat": 2,
"no-useless-rename": 2,
"no-var": 2,
"no-whitespace-before-property": 2,
"object-curly-newline": 0,
"object-curly-spacing": [
2,
"always"
],
"object-property-newline": [
2,
{
"allowMultiplePropertiesPerLine": true
}
],
"prefer-const": 2,
"prefer-rest-params": 2,
"prefer-spread": 2,
"prefer-template": 2,
"quotes": [
2,
"single",
{
"avoidEscape": true
}
],
"semi": [
2,
"always"
],
"semi-spacing": 2,
"space-before-blocks": 2,
"space-before-function-paren": [
2,
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"space-in-parens": [
2,
"never"
],
"spaced-comment": [
2,
"always"
],
"strict": 2,
"valid-typeof": 2,
"eol-last": 0,
"yoda": 2,
"import/extensions": 2,
"import/no-duplicates": 2,
"jsx-quotes": [
2,
"prefer-single"
],
"react/display-name": [
2,
{
"ignoreTranspilerName": false
}
],
"react/forbid-prop-types": 0,
"react/jsx-boolean-value": 2,
"react/jsx-closing-bracket-location": 2,
"react/jsx-curly-spacing": 2,
"react/jsx-equals-spacing": 2,
"react/jsx-handler-names": 2,
"react/jsx-indent-props": [
2,
"tab"
],
"react/jsx-indent": [
2,
"tab"
],
"react/jsx-key": 2,
"react/jsx-max-props-per-line": 0,
"react/jsx-no-bind": 0,
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-literals": 0,
"react/jsx-no-undef": 0,
"react/jsx-pascal-case": 2,
"react/jsx-sort-prop-types": 0,
"react/jsx-sort-props": 0,
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/no-danger": 2,
"react/no-deprecated": 2,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
"react/no-direct-mutation-state": 2,
"react/no-is-mounted": 2,
"react/no-multi-comp": 0,
"react/no-set-state": 0,
"react/no-string-refs": 0,
"react/no-unknown-property": 2,
"react/prefer-es6-class": 2,
"react/prop-types": [
2,
{
"skipUndeclared": true
}
],
"react/react-in-jsx-scope": 2,
"react/self-closing-comp": 2,
"react/sort-comp": 0,
"react/jsx-wrap-multilines": [
2,
{
"declaration": false,
"assignment": false,
"return": true
}
]
}
}

View File

@ -6,32 +6,32 @@
"author": "Håvar Aambø Fosstveit <h@fosstveit.net>", "author": "Håvar Aambø Fosstveit <h@fosstveit.net>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@material-ui/core": "^4.1.2", "@material-ui/core": "^4.5.1",
"@material-ui/icons": "^4.2.1", "@material-ui/icons": "^4.5.1",
"bowser": "^2.4.0", "bowser": "^2.7.0",
"create-torrent": "^3.33.0", "create-torrent": "^4.4.1",
"domready": "^1.0.8", "domready": "^1.0.8",
"file-saver": "^2.0.1", "file-saver": "^2.0.2",
"hark": "^1.2.3", "hark": "^1.2.3",
"marked": "^0.6.1", "marked": "^0.7.0",
"mediasoup-client": "^3.0.6", "mediasoup-client": "^3.2.7",
"notistack": "^0.5.1", "notistack": "^0.9.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"random-string": "^0.2.0", "random-string": "^0.2.0",
"react": "^16.8.5", "react": "^16.10.2",
"react-cookie-consent": "^2.2.2", "react-cookie-consent": "^2.5.0",
"react-dom": "^16.8.5", "react-dom": "^16.10.2",
"react-redux": "^6.0.1", "react-redux": "^7.1.1",
"react-scripts": "2.1.8", "react-scripts": "3.2.0",
"redux": "^4.0.1", "redux": "^4.0.4",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-persist": "^5.10.0", "redux-persist": "^6.0.0",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"riek": "^1.1.0", "riek": "^1.1.0",
"socket.io-client": "^2.2.0", "socket.io-client": "^2.3.0",
"source-map-explorer": "^1.8.0", "source-map-explorer": "^2.1.0",
"webtorrent": "^0.103.1" "webtorrent": "^0.107.16"
}, },
"scripts": { "scripts": {
"analyze-main": "source-map-explorer build/static/js/main.*", "analyze-main": "source-map-explorer build/static/js/main.*",
@ -41,342 +41,15 @@
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": {
"env": {
"browser": true,
"es6": true,
"node": true
},
"plugins": [
"import",
"react"
],
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"settings": {
"react": {
"pragma": "React",
"version": "16"
}
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true,
"jsx": true
}
},
"rules": {
"array-bracket-spacing": [
2,
"always",
{
"objectsInArrays": true,
"arraysInArrays": true
}
],
"arrow-parens": [
2,
"always"
],
"arrow-spacing": 2,
"block-spacing": [
2,
"always"
],
"brace-style": [
2,
"allman",
{
"allowSingleLine": true
}
],
"camelcase": 2,
"comma-dangle": 2,
"comma-spacing": [
2,
{
"before": false,
"after": true
}
],
"comma-style": 2,
"computed-property-spacing": 2,
"constructor-super": 2,
"func-call-spacing": 2,
"generator-star-spacing": 2,
"guard-for-in": 2,
"indent": [
2,
"tab",
{
"SwitchCase": 1
}
],
"key-spacing": [
2,
{
"singleLine": {
"beforeColon": false,
"afterColon": true
},
"multiLine": {
"beforeColon": true,
"afterColon": true,
"align": "colon"
}
}
],
"keyword-spacing": 2,
"linebreak-style": [
2,
"unix"
],
"lines-around-comment": [
2,
{
"allowBlockStart": true,
"allowObjectStart": true,
"beforeBlockComment": true,
"beforeLineComment": false
}
],
"max-len": [
2,
90,
{
"tabWidth": 2,
"comments": 110,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"newline-after-var": 2,
"newline-before-return": 2,
"newline-per-chained-call": 2,
"no-alert": 2,
"no-caller": 2,
"no-case-declarations": 2,
"no-catch-shadow": 2,
"no-class-assign": 2,
"no-confusing-arrow": [
"error",
{
"allowParens": true
}
],
"no-console": 2,
"no-const-assign": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-div-regex": 2,
"no-empty": [
2,
{
"allowEmptyCatch": true
}
],
"no-empty-pattern": 2,
"no-else-return": 0,
"no-eval": 2,
"no-extend-native": 2,
"no-ex-assign": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-label": 2,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-func-assign": 2,
"no-global-assign": 2,
"no-implicit-coercion": 2,
"no-implicit-globals": 2,
"no-inner-declarations": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-lonely-if": 2,
"no-mixed-operators": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [
2,
{
"max": 1,
"maxEOF": 0,
"maxBOF": 0
}
],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-prototype-builtins": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-imports": 2,
"no-return-assign": 2,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-undef": 2,
"no-unexpected-multiline": 2,
"no-unmodified-loop-condition": 2,
"no-unreachable": 2,
"no-unused-vars": [
1,
{
"vars": "all",
"args": "after-used"
}
],
"no-use-before-define": [
2,
{
"functions": false
}
],
"no-useless-call": 2,
"no-useless-computed-key": 2,
"no-useless-concat": 2,
"no-useless-rename": 2,
"no-var": 2,
"no-whitespace-before-property": 2,
"object-curly-newline": 0,
"object-curly-spacing": [
2,
"always"
],
"object-property-newline": [
2,
{
"allowMultiplePropertiesPerLine": true
}
],
"prefer-const": 2,
"prefer-rest-params": 2,
"prefer-spread": 2,
"prefer-template": 2,
"quotes": [
2,
"single",
{
"avoidEscape": true
}
],
"semi": [
2,
"always"
],
"semi-spacing": 2,
"space-before-blocks": 2,
"space-before-function-paren": [
2,
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"space-in-parens": [
2,
"never"
],
"spaced-comment": [
2,
"always"
],
"strict": 2,
"valid-typeof": 2,
"eol-last": 0,
"yoda": 2,
"import/extensions": 2,
"import/no-duplicates": 2,
"jsx-quotes": [
2,
"prefer-single"
],
"react/display-name": [
2,
{
"ignoreTranspilerName": false
}
],
"react/forbid-prop-types": 0,
"react/jsx-boolean-value": 2,
"react/jsx-closing-bracket-location": 2,
"react/jsx-curly-spacing": 2,
"react/jsx-equals-spacing": 2,
"react/jsx-handler-names": 2,
"react/jsx-indent-props": [
2,
"tab"
],
"react/jsx-indent": [
2,
"tab"
],
"react/jsx-key": 2,
"react/jsx-max-props-per-line": 0,
"react/jsx-no-bind": 0,
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-literals": 0,
"react/jsx-no-undef": 0,
"react/jsx-pascal-case": 2,
"react/jsx-sort-prop-types": 0,
"react/jsx-sort-props": 0,
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/no-danger": 2,
"react/no-deprecated": 2,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
"react/no-direct-mutation-state": 2,
"react/no-is-mounted": 2,
"react/no-multi-comp": 0,
"react/no-set-state": 0,
"react/no-string-refs": 0,
"react/no-unknown-property": 2,
"react/prefer-es6-class": 2,
"react/prop-types": [
2,
{
"skipUndeclared": true
}
],
"react/react-in-jsx-scope": 2,
"react/self-closing-comp": 2,
"react/sort-comp": 0,
"react/jsx-wrap-multilines": [
2,
{
"declaration": false,
"assignment": false,
"return": true
}
]
}
},
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",
"not dead", "not dead",
"not ie > 0", "not ie > 0",
"not op_mini all" "not op_mini all"
] ],
"devDependencies": {
"eslint": "^6.5.1",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-react": "^7.16.0"
}
} }

View File

@ -1,15 +1,30 @@
import io from 'socket.io-client'; // import io from 'socket.io-client';
import * as mediasoupClient from 'mediasoup-client'; // import * as mediasoupClient from 'mediasoup-client';
import WebTorrent from 'webtorrent'; // import WebTorrent from 'webtorrent';
import createTorrent from 'create-torrent'; // import createTorrent from 'create-torrent';
import saveAs from 'file-saver'; // import saveAs from 'file-saver';
import Logger from './Logger'; import Logger from './Logger';
import hark from 'hark'; import hark from 'hark';
import ScreenShare from './ScreenShare'; // import ScreenShare from './ScreenShare';
import Spotlights from './Spotlights'; // import Spotlights from './Spotlights';
import { getSignalingUrl } from './urlFactory'; import { getSignalingUrl } from './urlFactory';
import * as requestActions from './actions/requestActions'; import * as requestActions from './actions/requestActions';
import * as stateActions from './actions/stateActions'; import * as stateActions from './actions/stateActions';
let WebTorrent;
let createTorrent;
let saveAs;
let mediasoupClient;
let io;
let ScreenShare;
let Spotlights;
const { const {
turnServers, turnServers,
requestTimeout, requestTimeout,
@ -77,31 +92,25 @@ export default class RoomClient
} }
constructor( constructor(
{ roomId, peerId, device, useSimulcast, produce, consume, forceTcp }) { roomId, peerId, accessCode, device, useSimulcast, produce, forceTcp })
{ {
logger.debug( logger.debug(
'constructor() [roomId: "%s", peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", consume: "%s", forceTcp: "%s"]', 'constructor() [roomId: "%s", peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", forceTcp: "%s"]',
roomId, peerId, device.flag, useSimulcast, produce, consume, forceTcp); roomId, peerId, device.flag, useSimulcast, produce, forceTcp);
this._signalingUrl = getSignalingUrl(peerId, roomId); this._signalingUrl = getSignalingUrl(peerId, roomId);
// window element to external login site
this._loginWindow = null;
// Closed flag. // Closed flag.
this._closed = false; this._closed = false;
// Whether we should produce. // Whether we should produce.
this._produce = produce; this._produce = produce;
// Whether we should consume.
this._consume = consume;
// Wheter we force TCP // Wheter we force TCP
this._forceTcp = forceTcp; this._forceTcp = forceTcp;
// Torrent support // Torrent support
this._torrentSupport = WebTorrent.WEBRTC_SUPPORT; this._torrentSupport = null;
// Whether simulcast should be used. // Whether simulcast should be used.
this._useSimulcast = useSimulcast; this._useSimulcast = useSimulcast;
@ -112,6 +121,9 @@ export default class RoomClient
// My peer name. // My peer name.
this._peerId = peerId; this._peerId = peerId;
// Access code
this._accessCode = accessCode;
// Alert sound // Alert sound
this._soundAlert = new Audio('/sounds/notify.mp3'); this._soundAlert = new Audio('/sounds/notify.mp3');
@ -120,21 +132,14 @@ export default class RoomClient
// The room ID // The room ID
this._roomId = roomId; this._roomId = roomId;
store.dispatch(stateActions.setRoomName(roomId));
// mediasoup-client Device instance. // mediasoup-client Device instance.
// @type {mediasoupClient.Device} // @type {mediasoupClient.Device}
this._mediasoupDevice = null; this._mediasoupDevice = null;
this._doneJoining = false;
// Our WebTorrent client // Our WebTorrent client
this._webTorrent = this._torrentSupport && new WebTorrent({ this._webTorrent = null;
tracker : {
rtcConfig : {
iceServers : ROOM_OPTIONS.turnServers
}
}
});
// Max spotlights // Max spotlights
if (device.bowser.ios || device.bowser.mobile || device.bowser.android) if (device.bowser.ios || device.bowser.mobile || device.bowser.android)
@ -170,7 +175,7 @@ export default class RoomClient
// @type {Map<String, mediasoupClient.Consumer>} // @type {Map<String, mediasoupClient.Consumer>}
this._consumers = new Map(); this._consumers = new Map();
this._screenSharing = ScreenShare.create(device); this._screenSharing = null;
this._screenSharingProducer = null; this._screenSharingProducer = null;
@ -198,6 +203,8 @@ export default class RoomClient
this._recvTransport.close(); this._recvTransport.close();
store.dispatch(stateActions.setRoomState('closed')); store.dispatch(stateActions.setRoomState('closed'));
window.location = '/';
} }
_startKeyListener() _startKeyListener()
@ -305,19 +312,51 @@ export default class RoomClient
login() login()
{ {
const url = `/auth/login?roomId=${this._roomId}&peerId=${this._peerId}`; const url = `/auth/login?id=${this._peerId}`;
this._loginWindow = window.open(url, 'loginWindow'); window.open(url, 'loginWindow');
} }
logout() logout()
{ {
window.location = '/auth/logout'; window.open('/auth/logout', 'logoutWindow');
} }
closeLoginWindow() receiveLoginChildWindow(data)
{ {
this._loginWindow.close(); logger.debug('receiveFromChildWindow() | [data:"%o"]', data);
const { displayName, picture } = data;
if (store.getState().room.state === 'connected')
{
this.changeDisplayName(displayName);
this.changePicture(picture);
}
else
{
store.dispatch(stateActions.setDisplayName(displayName));
store.dispatch(stateActions.setPicture(picture));
}
store.dispatch(stateActions.loggedIn(true));
store.dispatch(requestActions.notify(
{
text : 'You are logged in.'
}));
}
receiveLogoutChildWindow()
{
logger.debug('receiveLogoutChildWindow()');
store.dispatch(stateActions.loggedIn(false));
store.dispatch(requestActions.notify(
{
text : 'You are logged out.'
}));
} }
_soundNotification() _soundNotification()
@ -399,6 +438,12 @@ export default class RoomClient
{ {
logger.debug('changeDisplayName() [displayName:"%s"]', displayName); logger.debug('changeDisplayName() [displayName:"%s"]', displayName);
if (!displayName)
displayName = 'Guest';
store.dispatch(
stateActions.setDisplayNameInProgress(true));
try try
{ {
await this.sendRequest('changeDisplayName', { displayName }); await this.sendRequest('changeDisplayName', { displayName });
@ -419,24 +464,23 @@ export default class RoomClient
type : 'error', type : 'error',
text : 'An error occured while changing your display name.' text : 'An error occured while changing your display name.'
})); }));
// We need to refresh the component for it to render the previous
// displayName again.
store.dispatch(stateActions.setDisplayName());
}
} }
async changeProfilePicture(picture) store.dispatch(
stateActions.setDisplayNameInProgress(false));
}
async changePicture(picture)
{ {
logger.debug('changeProfilePicture() [picture: "%s"]', picture); logger.debug('changePicture() [picture: "%s"]', picture);
try try
{ {
await this.sendRequest('changeProfilePicture', { picture }); await this.sendRequest('changePicture', { picture });
} }
catch (error) catch (error)
{ {
logger.error('shareProfilePicure() | failed: %o', error); logger.error('changePicture() | failed: %o', error);
} }
} }
@ -607,34 +651,46 @@ export default class RoomClient
const { const {
chatHistory, chatHistory,
fileHistory, fileHistory,
lastN lastNHistory,
locked,
lobbyPeers,
accessCode
} = await this.sendRequest('serverHistory'); } = await this.sendRequest('serverHistory');
if (chatHistory.length > 0) (chatHistory.length > 0) && store.dispatch(
{
logger.debug('Got chat history');
store.dispatch(
stateActions.addChatHistory(chatHistory)); stateActions.addChatHistory(chatHistory));
}
if (fileHistory.length > 0) (fileHistory.length > 0) && store.dispatch(
stateActions.addFileHistory(fileHistory));
if (lastNHistory.length > 0)
{ {
logger.debug('Got files history'); logger.debug('Got lastNHistory');
store.dispatch(stateActions.addFileHistory(fileHistory));
}
if (lastN.length > 0)
{
logger.debug('Got lastN');
// Remove our self from list // Remove our self from list
const index = lastN.indexOf(this._peerId); const index = lastNHistory.indexOf(this._peerId);
lastN.splice(index, 1); lastNHistory.splice(index, 1);
this._spotlights.addSpeakerList(lastN); this._spotlights.addSpeakerList(lastNHistory);
} }
locked ?
store.dispatch(stateActions.setRoomLocked()) :
store.dispatch(stateActions.setRoomUnLocked());
(lobbyPeers.length > 0) && lobbyPeers.forEach((peer) =>
{
store.dispatch(
stateActions.addLobbyPeer(peer.peerId));
store.dispatch(
stateActions.setLobbyPeerDisplayName(peer.displayName));
store.dispatch(
stateActions.setLobbyPeerPicture(peer.picture));
});
(accessCode != null) && store.dispatch(
stateActions.setAccessCode(accessCode));
} }
catch (error) catch (error)
{ {
@ -735,6 +791,22 @@ export default class RoomClient
} }
} }
async getAudioTrack()
{
await navigator.mediaDevices.getUserMedia(
{
audio : true, video : false
});
}
async getVideoTrack()
{
await navigator.mediaDevices.getUserMedia(
{
audio : false, video : true
});
}
async changeAudioDevice(deviceId) async changeAudioDevice(deviceId)
{ {
logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); logger.debug('changeAudioDevice() [deviceId: %s]', deviceId);
@ -934,6 +1006,26 @@ export default class RoomClient
stateActions.setSelectedPeer(peerId)); stateActions.setSelectedPeer(peerId));
} }
async promoteLobbyPeer(peerId)
{
logger.debug('promoteLobbyPeer() [peerId:"%s"]', peerId);
store.dispatch(
stateActions.setLobbyPeerPromotionInProgress(peerId, true));
try
{
await this.sendRequest('promotePeer', { peerId });
}
catch (error)
{
logger.error('promoteLobbyPeer() failed: %o', error);
}
store.dispatch(
stateActions.setLobbyPeerPromotionInProgress(peerId, false));
}
// type: mic/webcam/screen // type: mic/webcam/screen
// mute: true/false // mute: true/false
async modifyPeerConsumer(peerId, type, mute) async modifyPeerConsumer(peerId, type, mute)
@ -1061,13 +1153,78 @@ export default class RoomClient
stateActions.setMyRaiseHandStateInProgress(false)); stateActions.setMyRaiseHandStateInProgress(false));
} }
async _loadDynamicImports()
{
({ default: WebTorrent } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "webtorrent" */
'webtorrent'
));
({ default: createTorrent } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "create-torrent" */
'create-torrent'
));
({ default: saveAs } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "file-saver" */
'file-saver'
));
({ default: ScreenShare } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "screensharing" */
'./ScreenShare'
));
({ default: Spotlights } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "spotlights" */
'./Spotlights'
));
mediasoupClient = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "mediasoup" */
'mediasoup-client'
);
({ default: io } = await import(
/* webpackPrefetch: true */
/* webpackChunkName: "socket.io" */
'socket.io-client'
));
}
async join({ joinVideo }) async join({ joinVideo })
{ {
await this._loadDynamicImports();
this._torrentSupport = WebTorrent.WEBRTC_SUPPORT;
this._webTorrent = this._torrentSupport && new WebTorrent({
tracker : {
rtcConfig : {
iceServers : ROOM_OPTIONS.turnServers
}
}
});
this._screenSharing = ScreenShare.create(this._device);
this._signalingSocket = io(this._signalingUrl); this._signalingSocket = io(this._signalingUrl);
this._spotlights = new Spotlights(this._maxSpotlights, this._signalingSocket); this._spotlights = new Spotlights(this._maxSpotlights, this._signalingSocket);
store.dispatch(stateActions.toggleJoined());
store.dispatch(stateActions.setRoomState('connecting')); store.dispatch(stateActions.setRoomState('connecting'));
this._signalingSocket.on('connect', () => this._signalingSocket.on('connect', () =>
@ -1075,39 +1232,53 @@ export default class RoomClient
logger.debug('signaling Peer "connect" event'); logger.debug('signaling Peer "connect" event');
}); });
this._signalingSocket.on('disconnect', () => this._signalingSocket.on('disconnect', (reason) =>
{ {
logger.warn('signaling Peer "disconnect" event'); logger.warn('signaling Peer "disconnect" event [reason:"%s"]', reason);
if (this._closed)
return;
if (reason === 'io server disconnect')
{
store.dispatch(requestActions.notify(
{
text : 'You are disconnected.'
}));
this.close();
}
store.dispatch(requestActions.notify(
{
text : 'You are disconnected, attempting to reconnect.'
}));
store.dispatch(stateActions.setRoomState('connecting'));
});
this._signalingSocket.on('reconnect_failed', () =>
{
logger.warn('signaling Peer "reconnect_failed" event');
store.dispatch(requestActions.notify( store.dispatch(requestActions.notify(
{ {
text : 'You are disconnected.' text : 'You are disconnected.'
})); }));
// Close mediasoup Transports. this.close();
if (this._sendTransport)
{
this._sendTransport.close();
this._sendTransport = null;
}
if (this._recvTransport)
{
this._recvTransport.close();
this._recvTransport = null;
}
store.dispatch(stateActions.setRoomState('closed'));
}); });
this._signalingSocket.on('close', () => this._signalingSocket.on('reconnect', (attemptNumber) =>
{ {
if (this._closed) logger.debug('signaling Peer "reconnect" event [attempts:"%s"]', attemptNumber);
return;
logger.warn('signaling Peer "close" event'); store.dispatch(requestActions.notify(
{
text : 'You are reconnected.'
}));
this.close(); store.dispatch(stateActions.setRoomState('connected'));
}); });
this._signalingSocket.on('request', async (request, cb) => this._signalingSocket.on('request', async (request, cb) =>
@ -1241,18 +1412,35 @@ export default class RoomClient
'socket "notification" event [method:%s, data:%o]', 'socket "notification" event [method:%s, data:%o]',
notification.method, notification.data); notification.method, notification.data);
try
{
switch (notification.method) switch (notification.method)
{ {
case 'roomReady': case 'enteredLobby':
{ {
await this._joinRoom({ joinVideo }); store.dispatch(stateActions.setInLobby(true));
const { displayName } = store.getState().settings;
const { picture } = store.getState().me;
await this.sendRequest('changeDisplayName', { displayName });
await this.sendRequest('changePicture', { picture });
break;
}
case 'signInRequired':
{
store.dispatch(stateActions.setSignInRequired(true));
break; break;
} }
case 'roomLocked': case 'roomReady':
{ {
store.dispatch(stateActions.setRoomLockedOut()); store.dispatch(stateActions.toggleJoined());
store.dispatch(stateActions.setInLobby(false));
await this._joinRoom({ joinVideo });
break; break;
} }
@ -1283,6 +1471,118 @@ export default class RoomClient
break; break;
} }
case 'parkedPeer':
{
const { peerId } = notification.data;
store.dispatch(
stateActions.addLobbyPeer(peerId));
store.dispatch(
stateActions.setToolbarsVisible(true));
store.dispatch(requestActions.notify(
{
text : 'New participant entered the lobby.'
}));
break;
}
case 'lobby:peerClosed':
{
const { peerId } = notification.data;
store.dispatch(
stateActions.removeLobbyPeer(peerId));
store.dispatch(requestActions.notify(
{
text : 'Participant in lobby left.'
}));
break;
}
case 'lobby:promotedPeer':
{
const { peerId } = notification.data;
store.dispatch(
stateActions.removeLobbyPeer(peerId));
break;
}
case 'lobby:changeDisplayName':
{
const { peerId, displayName } = notification.data;
store.dispatch(
stateActions.setLobbyPeerDisplayName(displayName, peerId));
store.dispatch(requestActions.notify(
{
text : `Participant in lobby changed name to ${displayName}.`
}));
break;
}
case 'lobby:changePicture':
{
const { peerId, picture } = notification.data;
store.dispatch(
stateActions.setLobbyPeerPicture(picture, peerId));
store.dispatch(requestActions.notify(
{
text : 'Participant in lobby changed picture.'
}));
break;
}
case 'setAccessCode':
{
const { accessCode } = notification.data;
store.dispatch(
stateActions.setAccessCode(accessCode));
store.dispatch(requestActions.notify(
{
text : 'Access code for room updated'
}));
break;
}
case 'setJoinByAccessCode':
{
const { joinByAccessCode } = notification.data;
store.dispatch(
stateActions.setJoinByAccessCode(joinByAccessCode));
if (joinByAccessCode)
{
store.dispatch(requestActions.notify(
{
text : 'Access code for room is now activated'
}));
}
else
{
store.dispatch(requestActions.notify(
{
text : 'Access code for room is now deactivated'
}));
}
break;
}
case 'activeSpeaker': case 'activeSpeaker':
{ {
const { peerId } = notification.data; const { peerId } = notification.data;
@ -1311,7 +1611,7 @@ export default class RoomClient
break; break;
} }
case 'changeProfilePicture': case 'changePicture':
{ {
const { peerId, picture } = notification.data; const { peerId, picture } = notification.data;
@ -1320,26 +1620,6 @@ export default class RoomClient
break; break;
} }
case 'auth':
{
const { displayName, picture } = notification.data;
this.changeDisplayName(displayName);
this.changeProfilePicture(picture);
store.dispatch(stateActions.setPicture(picture));
store.dispatch(stateActions.loggedIn());
store.dispatch(requestActions.notify(
{
text : 'You are logged in.'
}));
this.closeLoginWindow();
break;
}
case 'chatMessage': case 'chatMessage':
{ {
const { peerId, chatMessage } = notification.data; const { peerId, chatMessage } = notification.data;
@ -1353,6 +1633,8 @@ export default class RoomClient
store.getState().toolarea.currentToolTab !== 'chat') store.getState().toolarea.currentToolTab !== 'chat')
) // Make sound ) // Make sound
{ {
store.dispatch(
stateActions.setToolbarsVisible(true));
this._soundNotification(); this._soundNotification();
} }
@ -1376,6 +1658,8 @@ export default class RoomClient
store.getState().toolarea.currentToolTab !== 'files') store.getState().toolarea.currentToolTab !== 'files')
) // Make sound ) // Make sound
{ {
store.dispatch(
stateActions.setToolbarsVisible(true));
this._soundNotification(); this._soundNotification();
} }
@ -1498,6 +1782,18 @@ export default class RoomClient
'unknown notification.method "%s"', notification.method); 'unknown notification.method "%s"', notification.method);
} }
} }
}
catch (error)
{
logger.error('error on socket "notification" event failed:"%o"', error);
store.dispatch(requestActions.notify(
{
type : 'error',
text : 'Error on server request.'
}));
}
}); });
} }
@ -1573,8 +1869,6 @@ export default class RoomClient
}); });
} }
if (this._consume)
{
const transportInfo = await this.sendRequest( const transportInfo = await this.sendRequest(
'createWebRtcTransport', 'createWebRtcTransport',
{ {
@ -1610,7 +1904,6 @@ export default class RoomClient
.then(callback) .then(callback)
.catch(errback); .catch(errback);
}); });
}
// Set our media capabilities. // Set our media capabilities.
store.dispatch(stateActions.setMediaCapabilities( store.dispatch(stateActions.setMediaCapabilities(
@ -1628,11 +1921,11 @@ export default class RoomClient
displayName : displayName, displayName : displayName,
picture : picture, picture : picture,
device : this._device, device : this._device,
rtpCapabilities : this._consume rtpCapabilities : this._mediasoupDevice.rtpCapabilities
? this._mediasoupDevice.rtpCapabilities
: undefined
}); });
logger.debug('_joinRoom() joined, got peers [peers:"%o"]', peers);
for (const peer of peers) for (const peer of peers)
{ {
store.dispatch( store.dispatch(
@ -1741,6 +2034,60 @@ export default class RoomClient
} }
} }
async setAccessCode(code)
{
logger.debug('setAccessCode()');
try
{
await this.sendRequest('setAccessCode', { accessCode: code });
store.dispatch(
stateActions.setAccessCode(code));
store.dispatch(requestActions.notify(
{
text : 'Access code saved.'
}));
}
catch (error)
{
logger.error('setAccessCode() | failed: %o', error);
store.dispatch(requestActions.notify(
{
type : 'error',
text : 'Unable to set access code.'
}));
}
}
async setJoinByAccessCode(value)
{
logger.debug('setJoinByAccessCode()');
try
{
await this.sendRequest('setJoinByAccessCode', { joinByAccessCode: value });
store.dispatch(
stateActions.setJoinByAccessCode(value));
store.dispatch(requestActions.notify(
{
text : `You switched Join by access-code to ${value}`
}));
}
catch (error)
{
logger.error('setAccessCode() | failed: %o', error);
store.dispatch(requestActions.notify(
{
type : 'error',
text : 'Unable to set join by access code.'
}));
}
}
async enableMic() async enableMic()
{ {
if (this._micProducer) if (this._micProducer)
@ -2278,7 +2625,7 @@ export default class RoomClient
try try
{ {
logger.debug('_getAudioDeviceId() | calling _updateWebcams()'); logger.debug('_getAudioDeviceId() | calling _updateAudioDeviceId()');
await this._updateAudioDevices(); await this._updateAudioDevices();

View File

@ -6,6 +6,14 @@ export const setRoomUrl = (url) =>
}; };
}; };
export const setRoomName = (name) =>
{
return {
type : 'SET_ROOM_NAME',
payload : { name }
};
};
export const setRoomState = (state) => export const setRoomState = (state) =>
{ {
return { return {
@ -36,10 +44,35 @@ export const setRoomUnLocked = () =>
}; };
}; };
export const setRoomLockedOut = () => export const setInLobby = (inLobby) =>
{ {
return { return {
type : 'SET_ROOM_LOCKED_OUT' type : 'SET_IN_LOBBY',
payload : { inLobby }
};
};
export const setSignInRequired = (signInRequired) =>
{
return {
type : 'SET_SIGN_IN_REQUIRED',
payload : { signInRequired }
};
};
export const setAccessCode = (accessCode) =>
{
return {
type : 'SET_ACCESS_CODE',
payload : { accessCode }
};
};
export const setJoinByAccessCode = (joinByAccessCode) =>
{
return {
type : 'SET_JOIN_BY_ACCESS_CODE',
payload : { joinByAccessCode }
}; };
}; };
@ -49,6 +82,12 @@ export const setSettingsOpen = ({ settingsOpen }) =>
payload : { settingsOpen } payload : { settingsOpen }
}); });
export const setLockDialogOpen = ({ lockDialogOpen }) =>
({
type : 'SET_LOCK_DIALOG_OPEN',
payload : { lockDialogOpen }
});
export const setMe = ({ peerId, device, loginEnabled }) => export const setMe = ({ peerId, device, loginEnabled }) =>
{ {
return { return {
@ -126,6 +165,14 @@ export const setDisplayName = (displayName) =>
}; };
}; };
export const setDisplayNameInProgress = (flag) =>
{
return {
type : 'SET_DISPLAY_NAME_IN_PROGRESS',
payload : { flag }
};
};
export const toggleAdvancedMode = () => export const toggleAdvancedMode = () =>
{ {
return { return {
@ -178,6 +225,13 @@ export const toggleSettings = () =>
}; };
}; };
export const toggleLockDialog = () =>
{
return {
type : 'TOGGLE_LOCK_DIALOG'
};
};
export const toggleToolArea = () => export const toggleToolArea = () =>
{ {
return { return {
@ -391,6 +445,46 @@ export const setPeerVolume = (peerId, volume) =>
}; };
}; };
export const addLobbyPeer = (peerId) =>
{
return {
type : 'ADD_LOBBY_PEER',
payload : { peerId }
};
};
export const removeLobbyPeer = (peerId) =>
{
return {
type : 'REMOVE_LOBBY_PEER',
payload : { peerId }
};
};
export const setLobbyPeerDisplayName = (displayName, peerId) =>
{
return {
type : 'SET_LOBBY_PEER_DISPLAY_NAME',
payload : { displayName, peerId }
};
};
export const setLobbyPeerPicture = (picture, peerId) =>
{
return {
type : 'SET_LOBBY_PEER_PICTURE',
payload : { picture, peerId }
};
};
export const setLobbyPeerPromotionInProgress = (peerId, flag) =>
{
return {
type : 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS',
payload : { peerId, flag }
};
};
export const addNotification = (notification) => export const addNotification = (notification) =>
{ {
return { return {
@ -555,9 +649,10 @@ export const setPeerPicture = (peerId, picture) =>
payload : { peerId, picture } payload : { peerId, picture }
}); });
export const loggedIn = () => export const loggedIn = (flag) =>
({ ({
type : 'LOGGED_IN' type : 'LOGGED_IN',
payload : { flag }
}); });
export const toggleJoined = () => export const toggleJoined = () =>

View File

@ -0,0 +1,148 @@
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/core/styles';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { withRoomContext } from '../../../RoomContext';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import EmptyAvatar from '../../../images/avatar-empty.jpeg';
import PromoteIcon from '@material-ui/icons/OpenInBrowser';
import Tooltip from '@material-ui/core/Tooltip';
const styles = (theme) =>
({
root :
{
padding : theme.spacing(1),
width : '100%',
overflow : 'hidden',
cursor : 'auto',
display : 'flex'
},
avatar :
{
borderRadius : '50%',
height : '2rem'
},
peerInfo :
{
fontSize : '1rem',
border : 'none',
display : 'flex',
paddingLeft : theme.spacing(1),
flexGrow : 1,
alignItems : 'center'
},
controls :
{
float : 'right',
display : 'flex',
flexDirection : 'row',
justifyContent : 'flex-start',
alignItems : 'center'
},
button :
{
flex : '0 0 auto',
margin : '0.3rem',
borderRadius : 2,
backgroundColor : 'rgba(0, 0, 0, 0.5)',
cursor : 'pointer',
transitionProperty : 'opacity, background-color',
transitionDuration : '0.15s',
width : 'var(--media-control-button-size)',
height : 'var(--media-control-button-size)',
opacity : 0.85,
'&:hover' :
{
opacity : 1
},
'&.disabled' :
{
pointerEvents : 'none',
backgroundColor : 'var(--media-control-botton-disabled)'
},
'&.promote' :
{
backgroundColor : 'var(--media-control-botton-on)'
}
},
ListItem :
{
alignItems : 'center'
}
});
const ListLobbyPeer = (props) =>
{
const {
roomClient,
peer,
classes
} = props;
const picture = peer.picture || EmptyAvatar;
return (
<ListItem
className={classnames(classes.ListItem)}
key={peer.peerId}
button
alignItems='flex-start'
>
<ListItemAvatar>
<Avatar alt='Peer avatar' src={picture} />
</ListItemAvatar>
<ListItemText
primary={peer.displayName}
/>
<Tooltip title='Click to let them in'>
<ListItemIcon
className={classnames(classes.button, 'promote', {
disabled : peer.promotionInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
roomClient.promoteLobbyPeer(peer.id);
}}
>
<PromoteIcon />
</ListItemIcon>
</Tooltip>
</ListItem>
);
};
ListLobbyPeer.propTypes =
{
roomClient : PropTypes.any.isRequired,
advancedMode : PropTypes.bool,
peer : PropTypes.object.isRequired,
classes : PropTypes.object.isRequired
};
const mapStateToProps = (state, { id }) =>
{
return {
peer : state.lobbyPeers[id]
};
};
export default withRoomContext(connect(
mapStateToProps,
null,
null,
{
areStatesEqual : (next, prev) =>
{
return (
prev.lobbyPeers === next.lobbyPeers
);
}
}
)(withStyles(styles)(ListLobbyPeer)));

View File

@ -0,0 +1,200 @@
import React from 'react';
import { connect } from 'react-redux';
import {
lobbyPeersKeySelector
} from '../../Selectors';
import * as appPropTypes from '../../appPropTypes';
import { withStyles } from '@material-ui/core/styles';
import { withRoomContext } from '../../../RoomContext';
import * as stateActions from '../../../actions/stateActions';
import PropTypes from 'prop-types';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import Button from '@material-ui/core/Button';
// import FormLabel from '@material-ui/core/FormLabel';
// import FormControl from '@material-ui/core/FormControl';
// import FormGroup from '@material-ui/core/FormGroup';
// import FormControlLabel from '@material-ui/core/FormControlLabel';
// import Checkbox from '@material-ui/core/Checkbox';
// import InputLabel from '@material-ui/core/InputLabel';
// import OutlinedInput from '@material-ui/core/OutlinedInput';
// import Switch from '@material-ui/core/Switch';
import List from '@material-ui/core/List';
import ListSubheader from '@material-ui/core/ListSubheader';
import ListLobbyPeer from './ListLobbyPeer';
const styles = (theme) =>
({
root :
{
},
dialogPaper :
{
width : '30vw',
[theme.breakpoints.down('lg')] :
{
width : '40vw'
},
[theme.breakpoints.down('md')] :
{
width : '50vw'
},
[theme.breakpoints.down('sm')] :
{
width : '70vw'
},
[theme.breakpoints.down('xs')] :
{
width : '90vw'
}
},
lock :
{
padding : theme.spacing(2)
}
});
const LockDialog = ({
// roomClient,
room,
handleCloseLockDialog,
// handleAccessCode,
lobbyPeers,
classes
}) =>
{
return (
<Dialog
className={classes.root}
open={room.lockDialogOpen}
onClose={() => handleCloseLockDialog({ lockDialogOpen: false })}
classes={{
paper : classes.dialogPaper
}}
>
<DialogTitle id='form-dialog-title'>Lobby administration</DialogTitle>
{/*
<FormControl component='fieldset' className={classes.formControl}>
<FormLabel component='legend'>Room lock</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Switch checked={room.locked} onChange={() =>
{
if (room.locked)
{
roomClient.unlockRoom();
}
else
{
roomClient.lockRoom();
}
}}
/>}
label='Lock'
/>
TODO: access code
<FormControlLabel disabled={ room.locked ? false : true }
control={
<Checkbox checked={room.joinByAccessCode}
onChange={
(event) => roomClient.setJoinByAccessCode(event.target.checked)
}
/>}
label='Join by Access code'
/>
<InputLabel htmlFor='access-code-input' />
<OutlinedInput
disabled={ room.locked ? false : true }
id='acces-code-input'
label='Access code'
labelWidth={0}
variant='outlined'
value={room.accessCode}
onChange={(event) => handleAccessCode(event.target.value)}
>
</OutlinedInput>
<Button onClick={() => roomClient.setAccessCode(room.accessCode)} color='primary'>
save
</Button>
</FormGroup>
</FormControl>
*/}
{ lobbyPeers.length > 0 ?
<List
dense
subheader={
<ListSubheader component='div'>
Participants in Lobby
</ListSubheader>
}
>
{
lobbyPeers.map((peerId) =>
{
return (<ListLobbyPeer key={peerId} id={peerId} />);
})
}
</List>
:
<DialogContent>
<DialogContentText gutterBottom>
There are currently no one in the lobby.
</DialogContentText>
</DialogContent>
}
<DialogActions>
<Button onClick={() => handleCloseLockDialog({ lockDialogOpen: false })} color='primary'>
Close
</Button>
</DialogActions>
</Dialog>
);
};
LockDialog.propTypes =
{
// roomClient : PropTypes.any.isRequired,
room : appPropTypes.Room.isRequired,
handleCloseLockDialog : PropTypes.func.isRequired,
handleAccessCode : PropTypes.func.isRequired,
lobbyPeers : PropTypes.array,
classes : PropTypes.object.isRequired
};
const mapStateToProps = (state) =>
{
return {
room : state.room,
lobbyPeers : lobbyPeersKeySelector(state)
};
};
const mapDispatchToProps = {
handleCloseLockDialog : stateActions.setLockDialogOpen,
handleAccessCode : stateActions.setAccessCode
};
export default withRoomContext(connect(
mapStateToProps,
mapDispatchToProps,
null,
{
areStatesEqual : (next, prev) =>
{
return (
prev.room.locked === next.room.locked &&
prev.room.joinByAccessCode === next.room.joinByAccessCode &&
prev.room.accessCode === next.room.accessCode &&
prev.room.code === next.room.code &&
prev.room.lockDialogOpen === next.room.lockDialogOpen &&
prev.room.codeHidden === next.room.codeHidden &&
prev.lobbyPeers === next.lobbyPeers
);
}
}
)(withStyles(styles)(LockDialog)));

View File

@ -0,0 +1,61 @@
import React, { useEffect, Suspense } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import JoinDialog from './JoinDialog';
import LoadingView from './LoadingView';
import { ReactLazyPreload } from './ReactLazyPreload';
const Room = ReactLazyPreload(() => import(/* webpackChunkName: "room" */ './Room'));
const App = (props) =>
{
const {
room
} = props;
useEffect(() =>
{
Room.preload();
return;
}, []);
if (!room.joined)
{
return (
<JoinDialog />
);
}
else
{
return (
<Suspense fallback={<LoadingView />}>
<Room />
</Suspense>
);
}
};
App.propTypes =
{
room : PropTypes.object.isRequired
};
const mapStateToProps = (state) =>
({
room : state.room
});
export default connect(
mapStateToProps,
null,
null,
{
areStatesEqual : (next, prev) =>
{
return (
prev.room === next.room
);
}
}
)(App);

View File

@ -96,7 +96,7 @@ class HiddenPeers extends React.PureComponent
onClick={() => openUsersTab()} onClick={() => openUsersTab()}
> >
<p>+{hiddenPeersCount} <br /> participant <p>+{hiddenPeersCount} <br /> participant
{(hiddenPeersCount === 1) ? null : 's'} {(hiddenPeersCount > 1) && 's'}
</p> </p>
</div> </div>
); );

View File

@ -332,13 +332,11 @@ const Me = (props) =>
} }
}} }}
> >
{ screenState === 'on' || screenState === 'unsupported' ? { (screenState === 'on' || screenState === 'unsupported') &&
<ScreenOffIcon/> <ScreenOffIcon/>
:null
} }
{ screenState === 'off' ? { screenState === 'off' &&
<ScreenIcon/> <ScreenIcon/>
:null
} }
</Fab> </Fab>
</div> </div>
@ -351,10 +349,10 @@ const Me = (props) =>
peer={me} peer={me}
displayName={settings.displayName} displayName={settings.displayName}
showPeerInfo showPeerInfo
videoTrack={webcamProducer ? webcamProducer.track : null} videoTrack={webcamProducer && webcamProducer.track}
videoVisible={videoVisible} videoVisible={videoVisible}
audioCodec={micProducer ? micProducer.codec : null} audioCodec={micProducer && micProducer.codec}
videoCodec={webcamProducer ? webcamProducer.codec : null} videoCodec={webcamProducer && webcamProducer.codec}
onChangeDisplayName={(displayName) => onChangeDisplayName={(displayName) =>
{ {
roomClient.changeDisplayName(displayName); roomClient.changeDisplayName(displayName);
@ -364,9 +362,9 @@ const Me = (props) =>
</VideoView> </VideoView>
</div> </div>
</div> </div>
{ screenProducer ? { screenProducer &&
<div <div
className={classnames(classes.root, 'screen', hover ? 'hover' : null)} className={classnames(classes.root, 'screen', hover && 'hover')}
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onTouchStart={() => onTouchStart={() =>
@ -390,7 +388,7 @@ const Me = (props) =>
> >
<div className={classnames(classes.viewContainer)} style={style}> <div className={classnames(classes.viewContainer)} style={style}>
<div <div
className={classnames(classes.controls, hover ? 'hover' : null)} className={classnames(classes.controls, hover && 'hover')}
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onTouchStart={() => onTouchStart={() =>
@ -420,13 +418,12 @@ const Me = (props) =>
isScreen isScreen
advancedMode={advancedMode} advancedMode={advancedMode}
videoContain videoContain
videoTrack={screenProducer ? screenProducer.track : null} videoTrack={screenProducer && screenProducer.track}
videoVisible={screenVisible} videoVisible={screenVisible}
videoCodec={screenProducer ? screenProducer.codec : null} videoCodec={screenProducer && screenProducer.codec}
/> />
</div> </div>
</div> </div>
:null
} }
</React.Fragment> </React.Fragment>
); );

View File

@ -166,8 +166,8 @@ const Peer = (props) =>
classnames( classnames(
classes.root, classes.root,
'webcam', 'webcam',
hover ? 'hover' : null, hover && 'hover',
activeSpeaker ? 'active-speaker' : null activeSpeaker && 'active-speaker'
) )
} }
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
@ -192,15 +192,14 @@ const Peer = (props) =>
style={rootStyle} style={rootStyle}
> >
<div className={classnames(classes.viewContainer)}> <div className={classnames(classes.viewContainer)}>
{ !videoVisible ? { !videoVisible &&
<div className={classes.videoInfo}> <div className={classes.videoInfo}>
<p>this video is paused</p> <p>this video is paused</p>
</div> </div>
:null
} }
<div <div
className={classnames(classes.controls, hover ? 'hover' : null)} className={classnames(classes.controls, hover && 'hover')}
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onTouchStart={() => onTouchStart={() =>
@ -241,7 +240,7 @@ const Peer = (props) =>
} }
</Fab> </Fab>
{ !smallScreen ? { !smallScreen &&
<Fab <Fab
aria-label='New window' aria-label='New window'
className={classes.fab} className={classes.fab}
@ -257,7 +256,6 @@ const Peer = (props) =>
> >
<NewWindowIcon /> <NewWindowIcon />
</Fab> </Fab>
:null
} }
<Fab <Fab
@ -279,20 +277,20 @@ const Peer = (props) =>
peer={peer} peer={peer}
displayName={peer.displayName} displayName={peer.displayName}
showPeerInfo showPeerInfo
videoTrack={webcamConsumer ? webcamConsumer.track : null} videoTrack={webcamConsumer && webcamConsumer.track}
videoVisible={videoVisible} videoVisible={videoVisible}
videoProfile={videoProfile} videoProfile={videoProfile}
audioCodec={micConsumer ? micConsumer.codec : null} audioCodec={micConsumer && micConsumer.codec}
videoCodec={webcamConsumer ? webcamConsumer.codec : null} videoCodec={webcamConsumer && webcamConsumer.codec}
> >
<Volume id={peer.id} /> <Volume id={peer.id} />
</VideoView> </VideoView>
</div> </div>
</div> </div>
{ screenConsumer ? { screenConsumer &&
<div <div
className={classnames(classes.root, 'screen', hover ? 'hover' : null)} className={classnames(classes.root, 'screen', hover && 'hover')}
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onTouchStart={() => onTouchStart={() =>
@ -314,17 +312,16 @@ const Peer = (props) =>
}} }}
style={rootStyle} style={rootStyle}
> >
{ !screenVisible ? { !screenVisible &&
<div className={classes.videoInfo}> <div className={classes.videoInfo}>
<p>this video is paused</p> <p>this video is paused</p>
</div> </div>
:null
} }
{ screenVisible ? { screenVisible &&
<div className={classnames(classes.viewContainer)}> <div className={classnames(classes.viewContainer)}>
<div <div
className={classnames(classes.controls, hover ? 'hover' : null)} className={classnames(classes.controls, hover && 'hover')}
onMouseOver={() => setHover(true)} onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onTouchStart={() => onTouchStart={() =>
@ -346,7 +343,7 @@ const Peer = (props) =>
}, 2000); }, 2000);
}} }}
> >
{ !smallScreen ? { !smallScreen &&
<Fab <Fab
aria-label='New window' aria-label='New window'
className={classes.fab} className={classes.fab}
@ -362,7 +359,6 @@ const Peer = (props) =>
> >
<NewWindowIcon /> <NewWindowIcon />
</Fab> </Fab>
:null
} }
<Fab <Fab
@ -381,16 +377,14 @@ const Peer = (props) =>
<VideoView <VideoView
advancedMode={advancedMode} advancedMode={advancedMode}
videoContain videoContain
videoTrack={screenConsumer ? screenConsumer.track : null} videoTrack={screenConsumer && screenConsumer.track}
videoVisible={screenVisible} videoVisible={screenVisible}
videoProfile={screenProfile} videoProfile={screenProfile}
videoCodec={screenConsumer ? screenConsumer.codec : null} videoCodec={screenConsumer && screenConsumer.codec}
/> />
</div> </div>
:null
} }
</div> </div>
:null
} }
</React.Fragment> </React.Fragment>
); );

View File

@ -117,11 +117,10 @@ const SpeakerPeer = (props) =>
style={spacingStyle} style={spacingStyle}
> >
<div className={classnames(classes.viewContainer)} style={style}> <div className={classnames(classes.viewContainer)} style={style}>
{ !videoVisible ? { !videoVisible &&
<div className={classes.videoInfo}> <div className={classes.videoInfo}>
<p>this video is paused</p> <p>this video is paused</p>
</div> </div>
:null
} }
<VideoView <VideoView
@ -140,18 +139,17 @@ const SpeakerPeer = (props) =>
</div> </div>
</div> </div>
{ screenConsumer ? { screenConsumer &&
<div <div
className={classnames(classes.root, 'screen')} className={classnames(classes.root, 'screen')}
> >
{ !screenVisible ? { !screenVisible &&
<div className={classes.videoInfo} style={style}> <div className={classes.videoInfo} style={style}>
<p>this video is paused</p> <p>this video is paused</p>
</div> </div>
:null
} }
{ screenVisible ? { screenVisible &&
<div className={classnames(classes.viewContainer)} style={style}> <div className={classnames(classes.viewContainer)} style={style}>
<VideoView <VideoView
advancedMode={advancedMode} advancedMode={advancedMode}
@ -162,10 +160,8 @@ const SpeakerPeer = (props) =>
videoCodec={screenConsumer ? screenConsumer.codec : null} videoCodec={screenConsumer ? screenConsumer.codec : null}
/> />
</div> </div>
:null
} }
</div> </div>
:null
} }
</React.Fragment> </React.Fragment>
); );

View File

@ -1,62 +1,251 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
import { withRoomContext } from '../RoomContext'; import { withRoomContext } from '../RoomContext';
import * as stateActions from '../actions/stateActions';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Dialog from '@material-ui/core/Dialog'; import Dialog from '@material-ui/core/Dialog';
import DialogContentText from '@material-ui/core/DialogContentText';
import IconButton from '@material-ui/core/IconButton';
import AccountCircle from '@material-ui/icons/AccountCircle';
import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Tooltip from '@material-ui/core/Tooltip';
import CookieConsent from 'react-cookie-consent';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
const styles = (theme) => const styles = (theme) =>
({ ({
root : root :
{
display : 'flex',
width : '100%',
height : '100%',
backgroundColor : 'var(--background-color)',
backgroundImage : `url(${window.config.background})`,
backgroundAttachment : 'fixed',
backgroundPosition : 'center',
backgroundSize : 'cover',
backgroundRepeat : 'no-repeat'
},
dialogTitle :
{ {
}, },
dialogPaper : dialogPaper :
{ {
width : '20vw', width : '30vw',
padding : theme.spacing(2), padding : theme.spacing(2),
[theme.breakpoints.down('lg')] : [theme.breakpoints.down('lg')] :
{ {
width : '30vw' width : '40vw'
}, },
[theme.breakpoints.down('md')] : [theme.breakpoints.down('md')] :
{ {
width : '40vw' width : '50vw'
}, },
[theme.breakpoints.down('sm')] : [theme.breakpoints.down('sm')] :
{ {
width : '60vw' width : '70vw'
}, },
[theme.breakpoints.down('xs')] : [theme.breakpoints.down('xs')] :
{ {
width : '80vw' width : '90vw'
} }
}, },
logo : logo :
{ {
display : 'block' display : 'block',
paddingBottom : '1vh'
},
loginButton :
{
position : 'absolute',
right : theme.spacing(2),
top : theme.spacing(2),
padding : 0
},
largeIcon :
{
fontSize : '2em'
},
largeAvatar :
{
width : 50,
height : 50
},
green :
{
color : 'rgba(0, 153, 0, 1)'
} }
}); });
const DialogTitle = withStyles(styles)((props) =>
{
const [ open, setOpen ] = useState(false);
useEffect(() =>
{
const openTimer = setTimeout(() => setOpen(true), 1000);
const closeTimer = setTimeout(() => setOpen(false), 4000);
return () =>
{
clearTimeout(openTimer);
clearTimeout(closeTimer);
};
}, []);
const { children, classes, myPicture, onLogin, ...other } = props;
const handleTooltipClose = () =>
{
setOpen(false);
};
const handleTooltipOpen = () =>
{
setOpen(true);
};
return (
<MuiDialogTitle disableTypography className={classes.dialogTitle} {...other}>
{ window.config.logo && <img alt='Logo' className={classes.logo} src={window.config.logo} /> }
<Typography variant='h5'>{children}</Typography>
{ window.config.loginEnabled &&
<Tooltip
onClose={handleTooltipClose}
onOpen={handleTooltipOpen}
open={open}
title='Click to log in'
placement='left'
>
<IconButton
aria-label='Account'
className={classes.loginButton}
color='inherit'
onClick={onLogin}
>
{ myPicture ?
<Avatar src={myPicture} className={classes.largeAvatar} />
:
<AccountCircle className={classes.largeIcon} />
}
</IconButton>
</Tooltip>
}
</MuiDialogTitle>
);
});
const DialogContent = withStyles((theme) => ({
root :
{
padding : theme.spacing(2)
}
}))(MuiDialogContent);
const DialogActions = withStyles((theme) => ({
root :
{
margin : 0,
padding : theme.spacing(1)
}
}))(MuiDialogActions);
const JoinDialog = ({ const JoinDialog = ({
roomClient, roomClient,
room,
displayName,
displayNameInProgress,
loggedIn,
myPicture,
changeDisplayName,
classes classes
}) => }) =>
{ {
const handleKeyDown = (event) =>
{
const { key } = event;
switch (key)
{
case 'Enter':
case 'Escape':
{
if (displayName === '')
changeDisplayName('Guest');
if (room.inLobby)
roomClient.changeDisplayName(displayName);
break;
}
default:
break;
}
};
return ( return (
<div className={classes.root}>
<Dialog <Dialog
className={classes.root}
open open
classes={{ classes={{
paper : classes.dialogPaper paper : classes.dialogPaper
}} }}
> >
{ window.config.logo ? <DialogTitle
<img alt='Logo' className={classes.logo} src={window.config.logo} /> myPicture={myPicture}
:null onLogin={() =>
} {
<Typography variant='subtitle1'>You are about to join a meeting, how would you like to join?</Typography> loggedIn ? roomClient.logout() : roomClient.login();
}}
>
{ window.config.title }
<hr />
</DialogTitle>
<DialogContent>
<DialogContentText gutterBottom>
You are about to join a meeting.
</DialogContentText>
<DialogContentText variant='h6' gutterBottom align='center'>
Room ID: { room.name }
</DialogContentText>
<DialogContentText gutterBottom>
Set your name for participation,
and choose how you want to join:
</DialogContentText>
<TextField
id='displayname'
label='Your name'
value={displayName}
variant='outlined'
margin='normal'
disabled={displayNameInProgress}
onChange={(event) =>
{
const { value } = event.target;
changeDisplayName(value);
}}
onKeyDown={handleKeyDown}
onBlur={() =>
{
if (displayName === '')
changeDisplayName('Guest');
if (room.inLobby)
roomClient.changeDisplayName(displayName);
}}
fullWidth
/>
</DialogContent>
{ !room.inLobby ?
<DialogActions> <DialogActions>
<Button <Button
onClick={() => onClick={() =>
@ -64,6 +253,7 @@ const JoinDialog = ({
roomClient.join({ joinVideo: false }); roomClient.join({ joinVideo: false });
}} }}
variant='contained' variant='contained'
color='secondary'
> >
Audio only Audio only
</Button> </Button>
@ -73,18 +263,93 @@ const JoinDialog = ({
roomClient.join({ joinVideo: true }); roomClient.join({ joinVideo: true });
}} }}
variant='contained' variant='contained'
color='secondary'
> >
Audio and Video Audio and Video
</Button> </Button>
</DialogActions> </DialogActions>
:
<DialogContent>
<DialogContentText
className={classes.green}
gutterBottom
variant='h6'
align='center'
>
Ok, you are ready
</DialogContentText>
{ room.signInRequired ?
<DialogContentText gutterBottom>
The room is empty!
You can Log In to start the meeting or wait until the host joins.
</DialogContentText>
:
<DialogContentText gutterBottom>
The room is locked - hang on until somebody lets you in ...
</DialogContentText>
}
</DialogContent>
}
<CookieConsent>
This website uses cookies to enhance the user experience.
</CookieConsent>
</Dialog> </Dialog>
</div>
); );
}; };
JoinDialog.propTypes = JoinDialog.propTypes =
{ {
roomClient : PropTypes.any.isRequired, roomClient : PropTypes.any.isRequired,
room : PropTypes.object.isRequired,
displayName : PropTypes.string.isRequired,
displayNameInProgress : PropTypes.bool.isRequired,
loginEnabled : PropTypes.bool.isRequired,
loggedIn : PropTypes.bool.isRequired,
myPicture : PropTypes.string,
changeDisplayName : PropTypes.func.isRequired,
classes : PropTypes.object.isRequired classes : PropTypes.object.isRequired
}; };
export default withRoomContext(withStyles(styles)(JoinDialog)); const mapStateToProps = (state) =>
{
return {
room : state.room,
displayName : state.settings.displayName,
displayNameInProgress : state.me.displayNameInProgress,
loginEnabled : state.me.loginEnabled,
loggedIn : state.me.loggedIn,
myPicture : state.me.picture
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
changeDisplayName : (displayName) =>
{
dispatch(stateActions.setDisplayName(displayName));
}
};
};
export default withRoomContext(connect(
mapStateToProps,
mapDispatchToProps,
null,
{
areStatesEqual : (next, prev) =>
{
return (
prev.room.inLobby === next.room.inLobby &&
prev.room.signInRequired === next.room.signInRequired &&
prev.settings.displayName === next.settings.displayName &&
prev.me.displayNameInProgress === next.me.displayNameInProgress &&
prev.me.loginEnabled === next.me.loginEnabled &&
prev.me.loggedIn === next.me.loggedIn &&
prev.me.picture === next.me.picture
);
}
}
)(withStyles(styles)(JoinDialog)));

View File

@ -123,7 +123,7 @@ ChatInput.propTypes =
const mapStateToProps = (state) => const mapStateToProps = (state) =>
({ ({
displayName : state.settings.displayName, displayName : state.settings.displayName,
picture : state.settings.picture picture : state.me.picture
}); });
export default withRoomContext( export default withRoomContext(
@ -136,7 +136,7 @@ export default withRoomContext(
{ {
return ( return (
prev.settings.displayName === next.settings.displayName && prev.settings.displayName === next.settings.displayName &&
prev.settings.picture === next.settings.picture prev.me.picture === next.me.picture
); );
} }
} }

View File

@ -97,7 +97,7 @@ MessageList.propTypes =
const mapStateToProps = (state) => const mapStateToProps = (state) =>
({ ({
chatmessages : state.chatmessages, chatmessages : state.chatmessages,
myPicture : state.settings.picture myPicture : state.me.picture
}); });
export default connect( export default connect(
@ -109,7 +109,7 @@ export default connect(
{ {
return ( return (
prev.chatmessages === next.chatmessages && prev.chatmessages === next.chatmessages &&
prev.settings.picture === next.settings.picture prev.me.picture === next.me.picture
); );
} }
} }

View File

@ -67,7 +67,7 @@ class File extends React.PureComponent
<img alt='Avatar' className={classes.avatar} src={picture} /> <img alt='Avatar' className={classes.avatar} src={picture} />
<div className={classes.fileContent}> <div className={classes.fileContent}>
{ file.files ? { file.files &&
<Fragment> <Fragment>
<Typography className={classes.text}> <Typography className={classes.text}>
File finished downloading File finished downloading
@ -92,13 +92,12 @@ class File extends React.PureComponent
</div> </div>
))} ))}
</Fragment> </Fragment>
:null
} }
<Typography className={classes.text}> <Typography className={classes.text}>
{ `${displayName} shared a file` } { `${displayName} shared a file` }
</Typography> </Typography>
{ !file.active && !file.files ? { (!file.active && !file.files) &&
<div className={classes.fileInfo}> <div className={classes.fileInfo}>
<Typography className={classes.text}> <Typography className={classes.text}>
{ magnet.decode(magnetUri).dn } { magnet.decode(magnetUri).dn }
@ -121,20 +120,17 @@ class File extends React.PureComponent
</Typography> </Typography>
} }
</div> </div>
:null
} }
{ file.timeout ? { file.timeout &&
<Typography className={classes.text}> <Typography className={classes.text}>
If this process takes a long time, there might not be anyone seeding If this process takes a long time, there might not be anyone seeding
this torrent. Try asking someone to reupload the file that you want. this torrent. Try asking someone to reupload the file that you want.
</Typography> </Typography>
:null
} }
{ file.active ? { file.active &&
<progress value={file.progress} /> <progress value={file.progress} />
:null
} }
</div> </div>
</div> </div>

View File

@ -45,7 +45,6 @@ class FileList extends React.PureComponent
const { const {
files, files,
me, me,
picture,
peers, peers,
classes classes
} = this.props; } = this.props;
@ -61,7 +60,7 @@ class FileList extends React.PureComponent
if (me.id === file.peerId) if (me.id === file.peerId)
{ {
displayName = 'You'; displayName = 'You';
filePicture = picture; filePicture = me.picture;
} }
else if (peers[file.peerId]) else if (peers[file.peerId])
{ {
@ -91,7 +90,6 @@ FileList.propTypes =
{ {
files : PropTypes.object.isRequired, files : PropTypes.object.isRequired,
me : appPropTypes.Me.isRequired, me : appPropTypes.Me.isRequired,
picture : PropTypes.string,
peers : PropTypes.object.isRequired, peers : PropTypes.object.isRequired,
classes : PropTypes.object.isRequired classes : PropTypes.object.isRequired
}; };
@ -101,7 +99,6 @@ const mapStateToProps = (state) =>
return { return {
files : state.files, files : state.files,
me : state.me, me : state.me,
picture : state.settings.picture,
peers : state.peers peers : state.peers
}; };
}; };
@ -116,7 +113,6 @@ export default connect(
return ( return (
prev.files === next.files && prev.files === next.files &&
prev.me === next.me && prev.me === next.me &&
prev.settings.picture === next.settings.picture &&
prev.peers === next.peers prev.peers === next.peers
); );
} }

View File

@ -79,7 +79,7 @@ const ListMe = (props) =>
classes classes
} = props; } = props;
const picture = settings.picture || EmptyAvatar; const picture = me.picture || EmptyAvatar;
return ( return (
<li className={classes.root}> <li className={classes.root}>
@ -91,9 +91,8 @@ const ListMe = (props) =>
</div> </div>
<div className={classes.indicators}> <div className={classes.indicators}>
{ me.raisedHand ? { me.raisedHand &&
<div className={classnames(classes.icon, 'raise-hand')} /> <div className={classnames(classes.icon, 'raise-hand')} />
:null
} }
</div> </div>
</div> </div>

View File

@ -159,7 +159,7 @@ const ListPeer = (props) =>
{peer.displayName} {peer.displayName}
</div> </div>
<div className={classes.indicators}> <div className={classes.indicators}>
{ peer.raiseHandState ? { peer.raiseHandState &&
<div className={ <div className={
classnames( classnames(
classes.icon, 'raise-hand', { classes.icon, 'raise-hand', {
@ -169,12 +169,11 @@ const ListPeer = (props) =>
) )
} }
/> />
:null
} }
</div> </div>
{children} {children}
<div className={classes.controls}> <div className={classes.controls}>
{ screenConsumer ? { screenConsumer &&
<div <div
className={classnames(classes.button, 'screen', { className={classnames(classes.button, 'screen', {
on : screenVisible, on : screenVisible,
@ -195,7 +194,6 @@ const ListPeer = (props) =>
<ScreenOffIcon /> <ScreenOffIcon />
} }
</div> </div>
:null
} }
<div <div
className={classnames(classes.button, 'mic', { className={classnames(classes.button, 'mic', {

View File

@ -87,7 +87,6 @@ class ParticipantList extends React.PureComponent
<li className={classes.listheader}>Me:</li> <li className={classes.listheader}>Me:</li>
<ListMe /> <ListMe />
</ul> </ul>
<br />
<ul className={classes.list}> <ul className={classes.list}>
<li className={classes.listheader}>Participants in Spotlight:</li> <li className={classes.listheader}>Participants in Spotlight:</li>
{ spotlightPeers.map((peer) => ( { spotlightPeers.map((peer) => (
@ -104,7 +103,6 @@ class ParticipantList extends React.PureComponent
</li> </li>
))} ))}
</ul> </ul>
<br />
<ul className={classes.list}> <ul className={classes.list}>
<li className={classes.listheader}>Passive Participants:</li> <li className={classes.listheader}>Passive Participants:</li>
{ passivePeers.map((peerId) => ( { passivePeers.map((peerId) => (

View File

@ -6,6 +6,7 @@ import {
videoBoxesSelector, videoBoxesSelector,
spotlightsLengthSelector spotlightsLengthSelector
} from '../Selectors'; } from '../Selectors';
import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
import Peer from '../Containers/Peer'; import Peer from '../Containers/Peer';
@ -14,7 +15,7 @@ import HiddenPeers from '../Containers/HiddenPeers';
const RATIO = 1.334; const RATIO = 1.334;
const PADDING_V = 50; const PADDING_V = 50;
const PADDING_H = 20; const PADDING_H = 0;
const styles = () => const styles = () =>
({ ({
@ -27,11 +28,17 @@ const styles = () =>
flexWrap : 'wrap', flexWrap : 'wrap',
justifyContent : 'center', justifyContent : 'center',
alignItems : 'center', alignItems : 'center',
alignContent : 'center', alignContent : 'center'
paddingTop : 40, },
paddingBottom : 10, hiddenToolBar :
paddingLeft : 10, {
paddingRight : 10 paddingTop : 0,
transition : 'padding .5s'
},
showingToolBar :
{
paddingTop : 60,
transition : 'padding .5s'
} }
}); });
@ -63,7 +70,8 @@ class Democratic extends React.PureComponent
} }
const width = this.peersRef.current.clientWidth - PADDING_H; const width = this.peersRef.current.clientWidth - PADDING_H;
const height = this.peersRef.current.clientHeight - PADDING_V; const height = this.peersRef.current.clientHeight -
(this.props.toolbarsVisible ? PADDING_V : PADDING_H);
let x, y, space; let x, y, space;
@ -86,8 +94,8 @@ class Democratic extends React.PureComponent
if (Math.ceil(this.state.peerWidth) !== Math.ceil(0.9 * x)) if (Math.ceil(this.state.peerWidth) !== Math.ceil(0.9 * x))
{ {
this.setState({ this.setState({
peerWidth : 0.9 * x, peerWidth : 0.95 * x,
peerHeight : 0.9 * y peerHeight : 0.95 * y
}); });
} }
}; };
@ -125,6 +133,7 @@ class Democratic extends React.PureComponent
peersLength, peersLength,
spotlightsPeers, spotlightsPeers,
spotlightsLength, spotlightsLength,
toolbarsVisible,
classes classes
} = this.props; } = this.props;
@ -135,7 +144,13 @@ class Democratic extends React.PureComponent
}; };
return ( return (
<div className={classes.root} ref={this.peersRef}> <div
className={classnames(
classes.root,
toolbarsVisible ? classes.showingToolBar : classes.hiddenToolBar
)}
ref={this.peersRef}
>
<Me <Me
advancedMode={advancedMode} advancedMode={advancedMode}
spacing={6} spacing={6}
@ -153,11 +168,10 @@ class Democratic extends React.PureComponent
/> />
); );
})} })}
{ spotlightsLength < peersLength ? { spotlightsLength < peersLength &&
<HiddenPeers <HiddenPeers
hiddenPeersCount={peersLength - spotlightsLength} hiddenPeersCount={peersLength - spotlightsLength}
/> />
:null
} }
</div> </div>
); );
@ -171,6 +185,7 @@ Democratic.propTypes =
boxes : PropTypes.number, boxes : PropTypes.number,
spotlightsLength : PropTypes.number, spotlightsLength : PropTypes.number,
spotlightsPeers : PropTypes.array.isRequired, spotlightsPeers : PropTypes.array.isRequired,
toolbarsVisible : PropTypes.bool.isRequired,
classes : PropTypes.object.isRequired classes : PropTypes.object.isRequired
}; };
@ -180,7 +195,8 @@ const mapStateToProps = (state) =>
peersLength : peersLengthSelector(state), peersLength : peersLengthSelector(state),
boxes : videoBoxesSelector(state), boxes : videoBoxesSelector(state),
spotlightsPeers : spotlightPeersSelector(state), spotlightsPeers : spotlightPeersSelector(state),
spotlightsLength : spotlightsLengthSelector(state) spotlightsLength : spotlightsLengthSelector(state),
toolbarsVisible : state.room.toolbarsVisible
}; };
}; };
@ -195,7 +211,8 @@ export default connect(
prev.peers === next.peers && prev.peers === next.peers &&
prev.producers === next.producers && prev.producers === next.producers &&
prev.consumers === next.consumers && prev.consumers === next.consumers &&
prev.room.spotlights === next.room.spotlights prev.room.spotlights === next.room.spotlights &&
prev.room.toolbarsVisible === next.room.toolbarsVisible
); );
} }
} }

View File

@ -228,13 +228,12 @@ class Filmstrip extends React.PureComponent
return ( return (
<div className={classes.root}> <div className={classes.root}>
<div className={classes.speaker} ref={this.activePeerContainer}> <div className={classes.speaker} ref={this.activePeerContainer}>
{ peers[activePeerId] ? { peers[activePeerId] &&
<SpeakerPeer <SpeakerPeer
advancedMode={advancedMode} advancedMode={advancedMode}
id={activePeerId} id={activePeerId}
style={speakerStyle} style={speakerStyle}
/> />
:null
} }
</div> </div>
@ -286,11 +285,10 @@ class Filmstrip extends React.PureComponent
</Grid> </Grid>
</div> </div>
{ spotlightsLength<Object.keys(peers).length ? { spotlightsLength<Object.keys(peers).length &&
<HiddenPeers <HiddenPeers
hiddenPeersCount={Object.keys(peers).length-spotlightsLength} hiddenPeersCount={Object.keys(peers).length-spotlightsLength}
/> />
:null
} }
</div> </div>
); );

View File

@ -29,7 +29,8 @@ export default class PeerAudio extends React.PureComponent
this._setTrack(audioTrack); this._setTrack(audioTrack);
} }
componentWillReceiveProps(nextProps) // eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps)
{ {
const { audioTrack } = nextProps; const { audioTrack } = nextProps;

View File

@ -0,0 +1,10 @@
import React from 'react';
export const ReactLazyPreload = (importStatement) =>
{
const Component = React.lazy(importStatement);
Component.preload = importStatement;
return Component;
};

View File

@ -1,6 +1,9 @@
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 {
lobbyPeersKeySelector
} from './Selectors';
import * as appPropTypes from './appPropTypes'; import * as appPropTypes from './appPropTypes';
import { withRoomContext } from '../RoomContext'; import { withRoomContext } from '../RoomContext';
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
@ -13,7 +16,6 @@ import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar'; import Toolbar from '@material-ui/core/Toolbar';
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'; import SwipeableDrawer from '@material-ui/core/SwipeableDrawer';
import Hidden from '@material-ui/core/Hidden'; import Hidden from '@material-ui/core/Hidden';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu'; import MenuIcon from '@material-ui/icons/Menu';
@ -30,11 +32,13 @@ import VideoWindow from './VideoWindow/VideoWindow';
import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenIcon from '@material-ui/icons/Fullscreen';
import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit';
import SettingsIcon from '@material-ui/icons/Settings'; import SettingsIcon from '@material-ui/icons/Settings';
import SecurityIcon from '@material-ui/icons/Security';
import LockDialog from './AccessControl/LockDialog/LockDialog';
import LockIcon from '@material-ui/icons/Lock'; import LockIcon from '@material-ui/icons/Lock';
import LockOpenIcon from '@material-ui/icons/LockOpen'; import LockOpenIcon from '@material-ui/icons/LockOpen';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import Settings from './Settings/Settings'; import Settings from './Settings/Settings';
import JoinDialog from './JoinDialog'; import Tooltip from '@material-ui/core/Tooltip';
const TIMEOUT = 10 * 1000; const TIMEOUT = 10 * 1000;
@ -150,6 +154,38 @@ const styles = (theme) =>
} }
}); });
const PulsingBadge = withStyles((theme) =>
({
badge :
{
backgroundColor : theme.palette.secondary.main,
// boxShadow : `0 0 0 2px ${theme.palette.secondary.main}`,
'&::after' :
{
position : 'absolute',
width : '100%',
height : '100%',
borderRadius : '50%',
animation : '$ripple 1.2s infinite ease-in-out',
border : `3px solid ${theme.palette.secondary.main}`,
content : '""'
}
},
'@keyframes ripple' :
{
'0%' :
{
transform : 'scale(.8)',
opacity : 1
},
'100%' :
{
transform : 'scale(2.4)',
opacity : 0
}
}
}))(Badge);
class Room extends React.PureComponent class Room extends React.PureComponent
{ {
constructor(props) constructor(props)
@ -227,11 +263,13 @@ class Room extends React.PureComponent
const { const {
roomClient, roomClient,
room, room,
lobbyPeers,
advancedMode, advancedMode,
myPicture, myPicture,
loggedIn, loggedIn,
loginEnabled, loginEnabled,
setSettingsOpen, setSettingsOpen,
setLockDialogOpen,
toolAreaOpen, toolAreaOpen,
toggleToolArea, toggleToolArea,
unread, unread,
@ -245,26 +283,6 @@ class Room extends React.PureComponent
democratic : Democratic democratic : Democratic
}[room.mode]; }[room.mode];
if (room.lockedOut)
{
return (
<div className={classes.root}>
<Paper className={classes.message}>
<Typography variant='h2'>This room is locked at the moment, try again later.</Typography>
</Paper>
</div>
);
}
else if (!room.joined)
{
return (
<div className={classes.root}>
<JoinDialog />
</div>
);
}
else
{
return ( return (
<div className={classes.root}> <div className={classes.root}>
<CookieConsent> <CookieConsent>
@ -286,7 +304,7 @@ class Room extends React.PureComponent
className={room.toolbarsVisible ? classes.show : classes.hide} className={room.toolbarsVisible ? classes.show : classes.hide}
> >
<Toolbar> <Toolbar>
<Badge <PulsingBadge
color='secondary' color='secondary'
badgeContent={unread} badgeContent={unread}
> >
@ -298,11 +316,8 @@ class Room extends React.PureComponent
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
</Badge> </PulsingBadge>
{ window.config.logo ? { window.config.logo && <img alt='Logo' className={classes.logo} src={window.config.logo} /> }
<img alt='Logo' className={classes.logo} src={window.config.logo} />
:null
}
<Typography <Typography
className={classes.title} className={classes.title}
variant='h6' variant='h6'
@ -313,6 +328,7 @@ class Room extends React.PureComponent
</Typography> </Typography>
<div className={classes.grow} /> <div className={classes.grow} />
<div className={classes.actionButtons}> <div className={classes.actionButtons}>
<Tooltip title={`${room.locked ? 'Unlock' : 'Lock'} room`}>
<IconButton <IconButton
aria-label='Lock room' aria-label='Lock room'
className={classes.actionButton} className={classes.actionButton}
@ -335,7 +351,25 @@ class Room extends React.PureComponent
<LockOpenIcon /> <LockOpenIcon />
} }
</IconButton> </IconButton>
{ this.fullscreen.fullscreenEnabled ? </Tooltip>
{ lobbyPeers.length > 0 &&
<Tooltip title='Show lobby'>
<IconButton
aria-label='Lobby'
color='inherit'
onClick={() => setLockDialogOpen(!room.lockDialogOpen)}
>
<PulsingBadge
color='secondary'
badgeContent={lobbyPeers.length}
>
<SecurityIcon />
</PulsingBadge>
</IconButton>
</Tooltip>
}
{ this.fullscreen.fullscreenEnabled &&
<Tooltip title={`${this.state.fullscreen ? 'Leave' : 'Enter'} fullscreen`}>
<IconButton <IconButton
aria-label='Fullscreen' aria-label='Fullscreen'
className={classes.actionButton} className={classes.actionButton}
@ -348,8 +382,9 @@ class Room extends React.PureComponent
<FullScreenIcon /> <FullScreenIcon />
} }
</IconButton> </IconButton>
:null </Tooltip>
} }
<Tooltip title='Show settings'>
<IconButton <IconButton
aria-label='Settings' aria-label='Settings'
className={classes.actionButton} className={classes.actionButton}
@ -358,7 +393,9 @@ class Room extends React.PureComponent
> >
<SettingsIcon /> <SettingsIcon />
</IconButton> </IconButton>
{ loginEnabled ? </Tooltip>
{ loginEnabled &&
<Tooltip title={`Log ${loggedIn ? 'out' : 'in'}`}>
<IconButton <IconButton
aria-label='Account' aria-label='Account'
className={classes.actionButton} className={classes.actionButton}
@ -374,7 +411,7 @@ class Room extends React.PureComponent
<AccountCircle /> <AccountCircle />
} }
</IconButton> </IconButton>
:null </Tooltip>
} }
<Button <Button
aria-label='Leave meeting' aria-label='Leave meeting'
@ -407,17 +444,19 @@ class Room extends React.PureComponent
<View advancedMode={advancedMode} /> <View advancedMode={advancedMode} />
<LockDialog />
<Settings /> <Settings />
</div> </div>
); );
} }
}
} }
Room.propTypes = Room.propTypes =
{ {
roomClient : PropTypes.object.isRequired, roomClient : PropTypes.object.isRequired,
room : appPropTypes.Room.isRequired, room : appPropTypes.Room.isRequired,
lobbyPeers : PropTypes.array,
advancedMode : PropTypes.bool.isRequired, advancedMode : PropTypes.bool.isRequired,
myPicture : PropTypes.string, myPicture : PropTypes.string,
loggedIn : PropTypes.bool.isRequired, loggedIn : PropTypes.bool.isRequired,
@ -425,6 +464,7 @@ Room.propTypes =
toolAreaOpen : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired,
setToolbarsVisible : PropTypes.func.isRequired, setToolbarsVisible : PropTypes.func.isRequired,
setSettingsOpen : PropTypes.func.isRequired, setSettingsOpen : PropTypes.func.isRequired,
setLockDialogOpen : PropTypes.func.isRequired,
toggleToolArea : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired,
unread : PropTypes.number.isRequired, unread : PropTypes.number.isRequired,
classes : PropTypes.object.isRequired, classes : PropTypes.object.isRequired,
@ -434,10 +474,11 @@ Room.propTypes =
const mapStateToProps = (state) => const mapStateToProps = (state) =>
({ ({
room : state.room, room : state.room,
lobbyPeers : lobbyPeersKeySelector(state),
advancedMode : state.settings.advancedMode, advancedMode : state.settings.advancedMode,
loggedIn : state.me.loggedIn, loggedIn : state.me.loggedIn,
loginEnabled : state.me.loginEnabled, loginEnabled : state.me.loginEnabled,
myPicture : state.settings.picture, myPicture : state.me.picture,
toolAreaOpen : state.toolarea.toolAreaOpen, toolAreaOpen : state.toolarea.toolAreaOpen,
unread : state.toolarea.unreadMessages + unread : state.toolarea.unreadMessages +
state.toolarea.unreadFiles state.toolarea.unreadFiles
@ -453,6 +494,10 @@ const mapDispatchToProps = (dispatch) =>
{ {
dispatch(stateActions.setSettingsOpen({ settingsOpen })); dispatch(stateActions.setSettingsOpen({ settingsOpen }));
}, },
setLockDialogOpen : (lockDialogOpen) =>
{
dispatch(stateActions.setLockDialogOpen({ lockDialogOpen }));
},
toggleToolArea : () => toggleToolArea : () =>
{ {
dispatch(stateActions.toggleToolArea()); dispatch(stateActions.toggleToolArea());
@ -468,9 +513,10 @@ export default withRoomContext(connect(
{ {
return ( return (
prev.room === next.room && prev.room === next.room &&
prev.lobbyPeers === next.lobbyPeers &&
prev.me.loggedIn === next.me.loggedIn && prev.me.loggedIn === next.me.loggedIn &&
prev.me.loginEnabled === next.me.loginEnabled && prev.me.loginEnabled === next.me.loginEnabled &&
prev.settings.picture === next.settings.picture && prev.me.picture === next.me.picture &&
prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen && prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen &&
prev.toolarea.unreadMessages === next.toolarea.unreadMessages && prev.toolarea.unreadMessages === next.toolarea.unreadMessages &&
prev.toolarea.unreadFiles === next.toolarea.unreadFiles && prev.toolarea.unreadFiles === next.toolarea.unreadFiles &&

View File

@ -4,6 +4,7 @@ const producersSelect = (state) => state.producers;
const consumersSelect = (state) => state.consumers; const consumersSelect = (state) => state.consumers;
const spotlightsSelector = (state) => state.room.spotlights; const spotlightsSelector = (state) => state.room.spotlights;
const peersSelector = (state) => state.peers; const peersSelector = (state) => state.peers;
const lobbyPeersSelector = (state) => state.lobbyPeers;
const getPeerConsumers = (state, props) => const getPeerConsumers = (state, props) =>
(state.peers[props.id] ? state.peers[props.id].consumers : null); (state.peers[props.id] ? state.peers[props.id].consumers : null);
const getAllConsumers = (state) => state.consumers; const getAllConsumers = (state) => state.consumers;
@ -12,6 +13,11 @@ const peersKeySelector = createSelector(
(peers) => Object.keys(peers) (peers) => Object.keys(peers)
); );
export const lobbyPeersKeySelector = createSelector(
lobbyPeersSelector,
(lobbyPeers) => Object.keys(lobbyPeers)
);
export const micProducersSelector = createSelector( export const micProducersSelector = createSelector(
producersSelect, producersSelect,
(producers) => Object.values(producers).filter((producer) => producer.source === 'mic') (producers) => Object.values(producers).filter((producer) => producer.source === 'mic')

View File

@ -171,24 +171,17 @@ class VideoView extends React.PureComponent
})} })}
> >
<div className={classes.box}> <div className={classes.box}>
{ audioCodec ? { audioCodec && <p>{audioCodec}</p> }
<p>{audioCodec}</p>
:null
}
{ videoCodec ? { videoCodec && <p>{videoCodec} {videoProfile}</p> }
<p>{videoCodec} {videoProfile}</p>
:null
}
{ (videoVisible && videoWidth !== null) ? { (videoVisible && videoWidth !== null) &&
<p>{videoWidth}x{videoHeight}</p> <p>{videoWidth}x{videoHeight}</p>
:null
} }
</div> </div>
</div> </div>
{ showPeerInfo ? { showPeerInfo &&
<div className={classes.peer}> <div className={classes.peer}>
<div className={classes.box}> <div className={classes.box}>
{ isMe ? { isMe ?
@ -212,17 +205,15 @@ class VideoView extends React.PureComponent
</span> </span>
} }
{ advancedMode ? { advancedMode &&
<div className={classes.deviceInfo}> <div className={classes.deviceInfo}>
<span> <span>
{peer.device.name} {Math.floor(peer.device.version) || null} {peer.device.name} {Math.floor(peer.device.version) || null}
</span> </span>
</div> </div>
:null
} }
</div> </div>
</div> </div>
:null
} }
</div> </div>
@ -256,7 +247,8 @@ class VideoView extends React.PureComponent
clearInterval(this._videoResolutionTimer); clearInterval(this._videoResolutionTimer);
} }
componentWillReceiveProps(nextProps) // eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps)
{ {
const { videoTrack } = nextProps; const { videoTrack } = nextProps;

View File

@ -130,7 +130,7 @@ class NewWindow extends React.PureComponent
return ReactDOM.createPortal([ return ReactDOM.createPortal([
<div key='newwindow' className={classes.root}> <div key='newwindow' className={classes.root}>
<div className={classes.controls}> <div className={classes.controls}>
{this.fullscreen.fullscreenEnabled && ( { this.fullscreen.fullscreenEnabled &&
<div <div
className={classes.button} className={classes.button}
onClick={this.handleToggleFullscreen} onClick={this.handleToggleFullscreen}
@ -144,9 +144,9 @@ class NewWindow extends React.PureComponent
<FullScreenIcon className={classes.icon} /> <FullScreenIcon className={classes.icon} />
} }
</div> </div>
)} }
</div> </div>
{this.props.children} { this.props.children }
</div> </div>
], this.container); ], this.container);
} }

View File

@ -18,6 +18,11 @@ html
font-family: 'Roboto'; font-family: 'Roboto';
font-weight: 300; font-weight: 300;
margin : 0; margin : 0;
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
} }
body body

View File

@ -9,7 +9,7 @@ import RoomClient from './RoomClient';
import RoomContext from './RoomContext'; import RoomContext from './RoomContext';
import deviceInfo from './deviceInfo'; import deviceInfo from './deviceInfo';
import * as stateActions from './actions/stateActions'; import * as stateActions from './actions/stateActions';
import Room from './components/Room'; import App from './components/App';
import LoadingView from './components/LoadingView'; import LoadingView from './components/LoadingView';
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
import { PersistGate } from 'redux-persist/lib/integration/react'; import { PersistGate } from 'redux-persist/lib/integration/react';
@ -19,7 +19,7 @@ import * as serviceWorker from './serviceWorker';
import './index.css'; import './index.css';
if (process.env.NODE_ENV !== 'production') if (process.env.REACT_APP_DEBUG === '*' || process.env.NODE_ENV !== 'production')
{ {
debug.enable('* -engine* -socket* -RIE* *WARN* *ERROR*'); debug.enable('* -engine* -socket* -RIE* *WARN* *ERROR*');
} }
@ -62,8 +62,8 @@ function run()
window.history.pushState('', '', urlParser.toString()); window.history.pushState('', '', urlParser.toString());
} }
const accessCode = parameters.get('code');
const produce = parameters.get('produce') !== 'false'; const produce = parameters.get('produce') !== 'false';
const consume = parameters.get('consume') !== 'false';
const useSimulcast = parameters.get('simulcast') === 'true'; const useSimulcast = parameters.get('simulcast') === 'true';
const forceTcp = parameters.get('forceTcp') === 'true'; const forceTcp = parameters.get('forceTcp') === 'true';
@ -84,7 +84,7 @@ function run()
); );
roomClient = new RoomClient( roomClient = new RoomClient(
{ roomId, peerId, device, useSimulcast, produce, consume, forceTcp }); { roomId, peerId, accessCode, device, useSimulcast, produce, forceTcp });
global.CLIENT = roomClient; global.CLIENT = roomClient;
@ -94,7 +94,7 @@ function run()
<PersistGate loading={<LoadingView />} persistor={persistor}> <PersistGate loading={<LoadingView />} persistor={persistor}>
<RoomContext.Provider value={roomClient}> <RoomContext.Provider value={roomClient}>
<SnackbarProvider> <SnackbarProvider>
<Room /> <App />
</SnackbarProvider> </SnackbarProvider>
</RoomContext.Provider> </RoomContext.Provider>
</PersistGate> </PersistGate>

View File

@ -0,0 +1,59 @@
const lobbyPeer = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_LOBBY_PEER':
return { id: action.payload.peerId };
case 'SET_LOBBY_PEER_DISPLAY_NAME':
return { ...state, displayName: action.payload.displayName };
case 'SET_LOBBY_PEER_PICTURE':
return { ...state, picture: action.payload.picture };
case 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS':
return { ...state, promotionInProgress: action.payload.flag };
default:
return state;
}
};
const lobbyPeers = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_LOBBY_PEER':
{
return { ...state, [action.payload.peerId]: lobbyPeer(undefined, action) };
}
case 'REMOVE_LOBBY_PEER':
{
const { peerId } = action.payload;
const newState = { ...state };
delete newState[peerId];
return newState;
}
case 'SET_LOBBY_PEER_DISPLAY_NAME':
case 'SET_LOBBY_PEER_PICTURE':
case 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS':
{
const oldLobbyPeer = state[action.payload.peerId];
if (!oldLobbyPeer)
{
// Tried to update non-existant lobbyPeer. Has probably been promoted, or left.
return state;
}
return { ...state, [oldLobbyPeer.id]: lobbyPeer(oldLobbyPeer, action) };
}
default:
return state;
}
};
export default lobbyPeers;

View File

@ -2,6 +2,7 @@ const initialState =
{ {
id : null, id : null,
device : null, device : null,
picture : null,
canSendMic : false, canSendMic : false,
canSendWebcam : false, canSendWebcam : false,
canShareScreen : false, canShareScreen : false,
@ -11,6 +12,7 @@ const initialState =
webcamInProgress : false, webcamInProgress : false,
audioInProgress : false, audioInProgress : false,
screenShareInProgress : false, screenShareInProgress : false,
displayNameInProgress : false,
loginEnabled : false, loginEnabled : false,
raiseHand : false, raiseHand : false,
raiseHandInProgress : false, raiseHandInProgress : false,
@ -38,11 +40,18 @@ const me = (state = initialState, action) =>
} }
case 'LOGGED_IN': case 'LOGGED_IN':
return { ...state, loggedIn: true }; {
const { flag } = action.payload;
return { ...state, loggedIn: flag };
}
case 'USER_LOGOUT': case 'USER_LOGOUT':
return { ...state, loggedIn: false }; return { ...state, loggedIn: false };
case 'SET_PICTURE':
return { ...state, picture: action.payload.picture };
case 'SET_MEDIA_CAPABILITIES': case 'SET_MEDIA_CAPABILITIES':
{ {
const { const {
@ -110,6 +119,13 @@ const me = (state = initialState, action) =>
return { ...state, raiseHandInProgress: flag }; return { ...state, raiseHandInProgress: flag };
} }
case 'SET_DISPLAY_NAME_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, displayNameInProgress: flag };
}
default: default:
return state; return state;
} }

View File

@ -1,9 +1,13 @@
const initialState = const initialState =
{ {
url : null, url : null,
name : '',
state : 'new', // new/connecting/connected/disconnected/closed, state : 'new', // new/connecting/connected/disconnected/closed,
locked : false, locked : false,
lockedOut : false, inLobby : false,
signInRequired : false,
accessCode : '', // access code to the room if locked and joinByAccessCode == true
joinByAccessCode : true, // if true: accessCode is a possibility to open the room
activeSpeakerId : null, activeSpeakerId : null,
torrentSupport : false, torrentSupport : false,
showSettings : false, showSettings : false,
@ -14,6 +18,7 @@ const initialState =
selectedPeerId : null, selectedPeerId : null,
spotlights : [], spotlights : [],
settingsOpen : false, settingsOpen : false,
lockDialogOpen : false,
joined : false joined : false
}; };
@ -28,6 +33,13 @@ const room = (state = initialState, action) =>
return { ...state, url }; return { ...state, url };
} }
case 'SET_ROOM_NAME':
{
const { name } = action.payload;
return { ...state, name };
}
case 'SET_ROOM_STATE': case 'SET_ROOM_STATE':
{ {
const roomState = action.payload.state; const roomState = action.payload.state;
@ -48,9 +60,39 @@ const room = (state = initialState, action) =>
return { ...state, locked: false }; return { ...state, locked: false };
} }
case 'SET_ROOM_LOCKED_OUT': case 'SET_IN_LOBBY':
{ {
return { ...state, lockedOut: true }; const { inLobby } = action.payload;
return { ...state, inLobby };
}
case 'SET_SIGN_IN_REQUIRED':
{
const { signInRequired } = action.payload;
return { ...state, signInRequired };
}
case 'SET_ACCESS_CODE':
{
const { accessCode } = action.payload;
return { ...state, accessCode };
}
case 'SET_JOIN_BY_ACCESS_CODE':
{
const { joinByAccessCode } = action.payload;
return { ...state, joinByAccessCode };
}
case 'SET_LOCK_DIALOG_OPEN':
{
const { lockDialogOpen } = action.payload;
return { ...state, lockDialogOpen };
} }
case 'SET_SETTINGS_OPEN': case 'SET_SETTINGS_OPEN':

View File

@ -3,6 +3,7 @@ import room from './room';
import me from './me'; import me from './me';
import producers from './producers'; import producers from './producers';
import peers from './peers'; import peers from './peers';
import lobbyPeers from './lobbyPeers';
import consumers from './consumers'; import consumers from './consumers';
import peerVolumes from './peerVolumes'; import peerVolumes from './peerVolumes';
import notifications from './notifications'; import notifications from './notifications';
@ -16,6 +17,7 @@ export default combineReducers({
me, me,
producers, producers,
peers, peers,
lobbyPeers,
consumers, consumers,
peerVolumes, peerVolumes,
notifications, notifications,

View File

@ -1,7 +1,6 @@
const initialState = const initialState =
{ {
displayName : 'Guest', displayName : 'Guest',
picture : null,
selectedWebcam : null, selectedWebcam : null,
selectedAudioDevice : null, selectedAudioDevice : null,
advancedMode : false, advancedMode : false,
@ -24,20 +23,11 @@ const settings = (state = initialState, action) =>
case 'SET_DISPLAY_NAME': case 'SET_DISPLAY_NAME':
{ {
let { displayName } = action.payload; const { displayName } = action.payload;
// Be ready for undefined displayName (so keep previous one).
if (!displayName)
displayName = state.displayName;
return { ...state, displayName }; return { ...state, displayName };
} }
case 'SET_PICTURE':
{
return { ...state, picture: action.payload.picture };
}
case 'TOGGLE_ADVANCED_MODE': case 'TOGGLE_ADVANCED_MODE':
{ {
const advancedMode = !state.advancedMode; const advancedMode = !state.advancedMode;

View File

@ -1,168 +0,0 @@
module.exports =
{
env:
{
browser: true,
es6: true,
node: true
},
extends:
[
'eslint:recommended'
],
settings: {},
parserOptions:
{
ecmaVersion: 6,
sourceType: 'module',
ecmaFeatures:
{
impliedStrict: true
}
},
rules:
{
'array-bracket-spacing': [ 2, 'always',
{
objectsInArrays: true,
arraysInArrays: true
}],
'arrow-parens': [ 2, 'always' ],
'arrow-spacing': 2,
'block-spacing': [ 2, 'always' ],
'brace-style': [ 2, 'allman', { allowSingleLine: true } ],
'camelcase': 2,
'comma-dangle': 2,
'comma-spacing': [ 2, { before: false, after: true } ],
'comma-style': 2,
'computed-property-spacing': 2,
'constructor-super': 2,
'func-call-spacing': 2,
'generator-star-spacing': 2,
'guard-for-in': 2,
'indent': [ 2, 'tab', { 'SwitchCase': 1 } ],
'key-spacing': [ 2,
{
singleLine:
{
beforeColon: false,
afterColon: true
},
multiLine:
{
beforeColon: true,
afterColon: true,
align: 'colon'
}
}],
'keyword-spacing': 2,
'linebreak-style': [ 2, 'unix' ],
'lines-around-comment': [ 2,
{
allowBlockStart: true,
allowObjectStart: true,
beforeBlockComment: true,
beforeLineComment: false
}],
'max-len': [ 2, 90,
{
tabWidth: 2,
comments: 110,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true
}],
'newline-after-var': 2,
'newline-before-return': 2,
'newline-per-chained-call': 2,
'no-alert': 2,
'no-caller': 2,
'no-case-declarations': 2,
'no-catch-shadow': 2,
'no-class-assign': 2,
'no-confusing-arrow': 2,
'no-console': 2,
'no-const-assign': 2,
'no-debugger': 2,
'no-dupe-args': 2,
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-div-regex': 2,
'no-empty': [ 2, { allowEmptyCatch: true } ],
'no-empty-pattern': 2,
'no-else-return': 0,
'no-eval': 2,
'no-extend-native': 2,
'no-ex-assign': 2,
'no-extra-bind': 2,
'no-extra-boolean-cast': 2,
'no-extra-label': 2,
'no-extra-semi': 2,
'no-fallthrough': 2,
'no-func-assign': 2,
'no-global-assign': 2,
'no-implicit-coercion': 2,
'no-implicit-globals': 2,
'no-inner-declarations': 2,
'no-invalid-regexp': 2,
'no-invalid-this': 2,
'no-irregular-whitespace': 2,
'no-lonely-if': 2,
'no-mixed-operators': 2,
'no-mixed-spaces-and-tabs': 2,
'no-multi-spaces': 2,
'no-multi-str': 2,
'no-multiple-empty-lines': [ 2, { max: 1, maxEOF: 0, maxBOF: 0 } ],
'no-native-reassign': 2,
'no-negated-in-lhs': 2,
'no-new': 2,
'no-new-func': 2,
'no-new-wrappers': 2,
'no-obj-calls': 2,
'no-proto': 2,
'no-prototype-builtins': 0,
'no-redeclare': 2,
'no-regex-spaces': 2,
'no-restricted-imports': 2,
'no-return-assign': 2,
'no-self-assign': 2,
'no-self-compare': 2,
'no-sequences': 2,
'no-shadow': 2,
'no-shadow-restricted-names': 2,
'no-spaced-func': 2,
'no-sparse-arrays': 2,
'no-this-before-super': 2,
'no-throw-literal': 2,
'no-undef': 2,
'no-unexpected-multiline': 2,
'no-unmodified-loop-condition': 2,
'no-unreachable': 2,
'no-unused-vars': [ 1, { vars: 'all', args: 'after-used' }],
'no-use-before-define': [ 2, { functions: false } ],
'no-useless-call': 2,
'no-useless-computed-key': 2,
'no-useless-concat': 2,
'no-useless-rename': 2,
'no-var': 2,
'no-whitespace-before-property': 2,
'object-curly-newline': 0,
'object-curly-spacing': [ 2, 'always' ],
'object-property-newline': [ 2, { allowMultiplePropertiesPerLine: true } ],
'prefer-const': 2,
'prefer-rest-params': 2,
'prefer-spread': 2,
'prefer-template': 2,
'quotes': [ 2, 'single', { avoidEscape: true } ],
'semi': [ 2, 'always' ],
'semi-spacing': 2,
'space-before-blocks': 2,
'space-before-function-paren': [ 2, 'never' ],
'space-in-parens': [ 2, 'never' ],
'spaced-comment': [ 2, 'always' ],
'strict': 0,
'valid-typeof': 2,
'yoda': 2
}
};

View File

@ -0,0 +1,171 @@
{
"env":
{
"es6": true,
"node": true
},
"extends":
[
"eslint:recommended"
],
"settings": {},
"parserOptions":
{
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures":
{
"impliedStrict": true
}
},
"rules":
{
"array-bracket-spacing": [ 2, "always",
{
"objectsInArrays": true,
"arraysInArrays": true
}],
"arrow-parens": [ 2, "always" ],
"arrow-spacing": 2,
"block-spacing": [ 2, "always" ],
"brace-style": [ 2, "allman", { "allowSingleLine": true } ],
"camelcase": 2,
"comma-dangle": 2,
"comma-spacing": [ 2, { "before": false, "after": true } ],
"comma-style": 2,
"computed-property-spacing": 2,
"constructor-super": 2,
"func-call-spacing": 2,
"generator-star-spacing": 2,
"guard-for-in": 2,
"indent": [ 2, "tab", { "SwitchCase": 1 } ],
"key-spacing": [ 2,
{
"singleLine":
{
"beforeColon": false,
"afterColon": true
},
"multiLine":
{
"beforeColon": true,
"afterColon": true,
"align": "colon"
}
}],
"keyword-spacing": 2,
"linebreak-style": [ 2, "unix" ],
"lines-around-comment": [ 2,
{
"allowBlockStart": true,
"allowObjectStart": true,
"beforeBlockComment": true,
"beforeLineComment": false
}],
"max-len": [ 2, 90,
{
"tabWidth": 2,
"comments": 90,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}],
"newline-after-var": 2,
"newline-before-return": 2,
"newline-per-chained-call": 2,
"no-alert": 2,
"no-caller": 2,
"no-case-declarations": 2,
"no-catch-shadow": 2,
"no-class-assign": 2,
"no-confusing-arrow": 2,
"no-console": 2,
"no-const-assign": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-div-regex": 2,
"no-empty": [ 2, { "allowEmptyCatch": true } ],
"no-empty-pattern": 2,
"no-else-return": 0,
"no-eval": 2,
"no-extend-native": 2,
"no-ex-assign": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-label": 2,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-func-assign": 2,
"no-global-assign": 2,
"no-implicit-coercion": 2,
"no-implicit-globals": 2,
"no-inner-declarations": 2,
"no-invalid-regexp": 2,
"no-invalid-this": 2,
"no-irregular-whitespace": 2,
"no-lonely-if": 2,
"no-mixed-operators": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [ 1, { "max": 1, "maxEOF": 0, "maxBOF": 0 } ],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-prototype-builtins": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-imports": 2,
"no-return-assign": 2,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-undef": 2,
"no-unexpected-multiline": 2,
"no-unmodified-loop-condition": 2,
"no-unreachable": 2,
"no-unused-vars": [ 1, { "vars": "all", "args": "after-used" }],
"no-use-before-define": [ 2, { "functions": false } ],
"no-useless-call": 2,
"no-useless-computed-key": 2,
"no-useless-concat": 2,
"no-useless-rename": 2,
"no-var": 2,
"no-whitespace-before-property": 2,
"object-curly-newline": 0,
"object-curly-spacing": [ 2, "always" ],
"object-property-newline": [ 2, { "allowMultiplePropertiesPerLine": true } ],
"prefer-const": 2,
"prefer-rest-params": 2,
"prefer-spread": 2,
"prefer-template": 2,
"quotes": [ 2, "single", { "avoidEscape": true } ],
"semi": [ 2, "always" ],
"semi-spacing": 2,
"space-before-blocks": 2,
"space-before-function-paren": [ 2,
{
"anonymous" : "never",
"named" : "never",
"asyncArrow" : "always"
}],
"space-in-parens": [ 2, "never" ],
"spaced-comment": [ 2, "always" ],
"strict": 2,
"valid-typeof": 2,
"yoda": 2
}
}

View File

@ -23,6 +23,7 @@ module.exports =
},*/ },*/
// session cookie secret // session cookie secret
cookieSecret : 'T0P-S3cR3t_cook!e', cookieSecret : 'T0P-S3cR3t_cook!e',
cookieName : 'multiparty-meeting.sid',
tls : tls :
{ {
cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`, cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`,
@ -33,6 +34,16 @@ module.exports =
// Any http request is redirected to https. // Any http request is redirected to https.
// Listening port for http server. // Listening port for http server.
listeningRedirectPort : 80, listeningRedirectPort : 80,
// If this is set to true, only signed-in users will be able
// to join a room directly. Non-signed-in users (guests) will
// always be put in the lobby regardless of room lock status.
// If false, there is no difference between guests and signed-in
// users when joining.
requireSignInToAccess : true,
// This flag has no effect when requireSignInToAccess is false
// When truthy, the room will be open to all users when the first
// authenticated user has already joined the room.
activateOnHostJoin : true,
// Mediasoup settings // Mediasoup settings
mediasoup : mediasoup :
{ {

View File

@ -1,44 +0,0 @@
/**
* Tasks:
*
* gulp lint
* Checks source code
*
* gulp watch
* Observes changes in the code
*
* gulp
* Invokes both `lint` and `watch` tasks
*/
const gulp = require('gulp');
const plumber = require('gulp-plumber');
const eslint = require('gulp-eslint');
const LINTING_FILES =
[
'gulpfile.js',
'server.js',
'config/config.example.js',
'lib/**/*.js'
];
gulp.task('lint', () =>
{
return gulp.src(LINTING_FILES)
.pipe(plumber())
.pipe(eslint())
.pipe(eslint.format());
});
gulp.task('lint-fix', function()
{
return gulp.src(LINTING_FILES)
.pipe(plumber())
.pipe(eslint({ fix: true }))
.pipe(eslint.format())
.pipe(gulp.dest((file) => file.base));
});
gulp.task('default', gulp.series('lint'));

View File

@ -1,43 +0,0 @@
'use strict';
const headers = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
'access-control-allow-headers': 'content-type, accept',
'access-control-max-age': 10,
'Content-Type': 'application/json'
};
exports.prepareResponse = (req, cb) =>
{
let data = '';
req.on('data', (chunk) =>
{
data += chunk;
});
req.on('end', () =>
{
cb(data);
});
};
exports.respond = (res, data, status) =>
{
status = status || 200;
res.writeHead(status, headers);
res.end(data);
};
exports.send404 = (res) =>
{
exports.respond(res, 'Not Found', 404);
};
exports.redirector = (res, loc, status) =>
{
status = status || 302;
res.writeHead(status, { Location: loc });
res.end();
};

View File

@ -0,0 +1,41 @@
exports.loginHelper = function(data)
{
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>Multiparty Meeting</title>
</head>
<body>
<script type='text/javascript'>
let data = ${JSON.stringify(data)};
window.opener.CLIENT.receiveLoginChildWindow(data);
window.close();
</script>
</body>
</html>`;
return html;
};
exports.logoutHelper = function()
{
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>Multiparty Meeting</title>
</head>
<body>
<script type='text/javascript'>
window.opener.CLIENT.receiveLogoutChildWindow();
window.close();
</script>
</body>
</html>`;
return html;
};

203
server/lib/Lobby.js 100644
View File

@ -0,0 +1,203 @@
const EventEmitter = require('events').EventEmitter;
const Logger = require('./Logger');
const logger = new Logger('Lobby');
class Lobby extends EventEmitter
{
constructor()
{
logger.info('constructor()');
super();
// Closed flag.
this._closed = false;
this._peers = new Map();
}
close()
{
logger.info('close()');
this._closed = true;
this._peers.forEach((peer) =>
{
if (!peer.closed)
peer.close();
});
this._peers.clear();
}
checkEmpty()
{
logger.info('checkEmpty()');
return this._peers.size === 0;
}
peerList()
{
logger.info('peerList()');
return Array.from(this._peers.values()).map((peer) =>
({
peerId : peer.id,
displayName : peer.displayName
}));
}
hasPeer(peerId)
{
return this._peers.has(peerId);
}
promoteAllPeers()
{
logger.info('promoteAllPeers()');
this._peers.forEach((peer) =>
{
if (peer.socket)
this.promotePeer(peer.id);
});
}
promotePeer(peerId)
{
logger.info('promotePeer() [peer:"%s"]', peerId);
const peer = this._peers.get(peerId);
if (peer)
{
peer.socket.removeListener('request', peer.socketRequestHandler);
peer.removeListener('authenticationChanged', peer.authenticationHandler);
peer.removeListener('close', peer.closeHandler);
peer.socketRequestHandler = null;
peer.authenticationHandler = null;
peer.closeHandler = null;
this.emit('promotePeer', peer);
this._peers.delete(peerId);
}
}
parkPeer(peer)
{
logger.info('parkPeer() [peer:"%s"]', peer.id);
if (this._closed)
return;
peer.socketRequestHandler = (request, cb) =>
{
logger.debug(
'Peer "request" event [method:"%s", peer:"%s"]',
request.method, peer.id);
if (this._closed)
return;
this._handleSocketRequest(peer, request, cb)
.catch((error) =>
{
logger.error('request failed [error:"%o"]', error);
cb(error);
});
};
peer.authenticationHandler = () =>
{
logger.info('parkPeer() | authenticationChange [peer:"%s"]', peer.id);
peer.authenticated && this.emit('peerAuthenticated', peer);
};
peer.closeHandler = () =>
{
logger.debug('Peer "close" event [peer:"%s"]', peer.id);
if (this._closed)
return;
this.emit('peerClosed', peer);
this._peers.delete(peer.id);
if (this.checkEmpty())
this.emit('lobbyEmpty');
};
this._notification(peer.socket, 'enteredLobby');
this._peers.set(peer.id, peer);
peer.on('authenticationChanged', peer.authenticationHandler);
peer.socket.on('request', peer.socketRequestHandler);
peer.on('close', peer.closeHandler);
}
async _handleSocketRequest(peer, request, cb)
{
logger.debug(
'_handleSocketRequest [peer:"%s"], [request:"%s"]',
peer.id,
request.method
);
if (this._closed)
return;
switch (request.method)
{
case 'changeDisplayName':
{
const { displayName } = request.data;
peer.displayName = displayName;
this.emit('changeDisplayName', peer);
cb();
break;
}
case 'changePicture':
{
const { picture } = request.data;
peer.picture = picture;
this.emit('changePicture', peer);
cb();
break;
}
}
}
_notification(socket, method, data = {}, broadcast = false)
{
if (broadcast)
{
socket.broadcast.to(this._roomId).emit(
'notification', { method, data }
);
}
else
{
socket.emit('notification', { method, data });
}
}
}
module.exports = Lobby;

View File

@ -1,5 +1,3 @@
'use strict';
const debug = require('debug'); const debug = require('debug');
const APP_NAME = 'multiparty-meeting-server'; const APP_NAME = 'multiparty-meeting-server';

342
server/lib/Peer.js 100644
View File

@ -0,0 +1,342 @@
const EventEmitter = require('events').EventEmitter;
const Logger = require('./Logger');
const logger = new Logger('Peer');
class Peer extends EventEmitter
{
constructor({ id, socket })
{
logger.info('constructor() [id:"%s", socket:"%s"]', id, socket.id);
super();
this._id = id;
this._authId = null;
this._socket = socket;
this._closed = false;
this._joined = false;
this._inLobby = false;
this._authenticated = false;
this._displayName = false;
this._picture = null;
this._email = null;
this._device = null;
this._rtpCapabilities = null;
this._raisedHand = false;
this._transports = new Map();
this._producers = new Map();
this._consumers = new Map();
this._checkAuthentication();
this._handlePeer();
}
close()
{
logger.info('close()');
this._closed = true;
// Iterate and close all mediasoup Transport associated to this Peer, so all
// its Producers and Consumers will also be closed.
this.transports.forEach((transport) =>
{
transport.close();
});
if (this._socket)
this._socket.disconnect(true);
this.emit('close');
}
_handlePeer()
{
this.socket.use((packet, next) =>
{
this._checkAuthentication();
return next();
});
this.socket.on('disconnect', () =>
{
if (this.closed)
return;
logger.debug('"disconnect" event [id:%s]', this.id);
this.close();
});
}
_checkAuthentication()
{
if (
Boolean(this.socket.handshake.session.passport) &&
Boolean(this.socket.handshake.session.passport.user)
)
{
const {
id,
displayName,
picture,
email
} = this.socket.handshake.session.passport.user;
id && (this.authId = id);
displayName && (this.displayName = displayName);
picture && (this.picture = picture);
email && (this.email = email);
this.authenticated = true;
}
else
{
this.authenticated = false;
}
}
get id()
{
return this._id;
}
set id(id)
{
this._id = id;
}
get authId()
{
return this._authId;
}
set authId(authId)
{
this._authId = authId;
}
get socket()
{
return this._socket;
}
set socket(socket)
{
this._socket = socket;
}
get closed()
{
return this._closed;
}
get joined()
{
return this._joined;
}
set joined(joined)
{
this._joined = joined;
}
get inLobby()
{
return this._inLobby;
}
set inLobby(inLobby)
{
this._inLobby = inLobby;
}
get authenticated()
{
return this._authenticated;
}
set authenticated(authenticated)
{
if (authenticated !== this._authenticated)
{
const oldAuthenticated = this._authenticated;
this._authenticated = authenticated;
this.emit('authenticationChanged', { oldAuthenticated });
}
}
get displayName()
{
return this._displayName;
}
set displayName(displayName)
{
if (displayName !== this._displayName)
{
const oldDisplayName = this._displayName;
this._displayName = displayName;
this.emit('displayNameChanged', { oldDisplayName });
}
}
get picture()
{
return this._picture;
}
set picture(picture)
{
if (picture !== this._picture)
{
const oldPicture = this._picture;
this._picture = picture;
this.emit('pictureChanged', { oldPicture });
}
}
get email()
{
return this._email;
}
set email(email)
{
this._email = email;
}
get device()
{
return this._device;
}
set device(device)
{
this._device = device;
}
get rtpCapabilities()
{
return this._rtpCapabilities;
}
set rtpCapabilities(rtpCapabilities)
{
this._rtpCapabilities = rtpCapabilities;
}
get raisedHand()
{
return this._raisedHand;
}
set raisedHand(raisedHand)
{
this._raisedHand = raisedHand;
}
get transports()
{
return this._transports;
}
get producers()
{
return this._producers;
}
get consumers()
{
return this._consumers;
}
addTransport(id, transport)
{
this.transports.set(id, transport);
}
getTransport(id)
{
return this.transports.get(id);
}
getConsumerTransport()
{
return Array.from(this.transports.values())
.find((t) => t.appData.consuming);
}
removeTransport(id)
{
this.transports.delete(id);
}
addProducer(id, producer)
{
this.producers.set(id, producer);
}
getProducer(id)
{
return this.producers.get(id);
}
removeProducer(id)
{
this.producers.delete(id);
}
addConsumer(id, consumer)
{
this.consumers.set(id, consumer);
}
getConsumer(id)
{
return this.consumers.get(id);
}
removeConsumer(id)
{
this.consumers.delete(id);
}
get peerInfo()
{
const peerInfo =
{
id : this.id,
displayName : this.displayName,
picture : this.picture,
device : this.device
};
return peerInfo;
}
}
module.exports = Peer;

File diff suppressed because it is too large Load Diff

View File

@ -1,321 +0,0 @@
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/config');
// mediasoup server.
const mediaServer = mediasoup.Server(
{
numWorkers : null,
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;

View File

@ -9,20 +9,21 @@
"dependencies": { "dependencies": {
"awaitqueue": "^1.0.0", "awaitqueue": "^1.0.0",
"base-64": "^0.1.0", "base-64": "^0.1.0",
"colors": "^1.1.2", "body-parser": "^1.19.0",
"compression": "^1.7.3", "colors": "^1.4.0",
"debug": "^4.1.0", "compression": "^1.7.4",
"express": "^4.16.3", "connect-redis": "^4.0.3",
"express-session": "^1.16.1", "cookie-parser": "^1.4.4",
"debug": "^4.1.1",
"express": "^4.17.1",
"express-session": "^1.17.0",
"express-socket.io-session": "^1.3.5",
"helmet": "^3.21.2",
"mediasoup": "^3.0.12", "mediasoup": "^3.0.12",
"openid-client": "^2.5.0", "openid-client": "^3.7.3",
"passport": "^0.4.0", "passport": "^0.4.0",
"socket.io": "^2.1.1", "redis": "^2.8.0",
"spdy": "^4.0.0" "socket.io": "^2.3.0",
}, "spdy": "^4.0.1"
"devDependencies": {
"gulp": "^4.0.0",
"gulp-eslint": "^5.0.0",
"gulp-plumber": "^1.2.0"
} }
} }

View File

@ -1,7 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
'use strict';
process.title = 'multiparty-meeting-server'; process.title = 'multiparty-meeting-server';
const config = require('./config/config'); const config = require('./config/config');
@ -9,17 +7,28 @@ const fs = require('fs');
const http = require('http'); const http = require('http');
const spdy = require('spdy'); const spdy = require('spdy');
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const compression = require('compression'); const compression = require('compression');
const mediasoup = require('mediasoup'); const mediasoup = require('mediasoup');
const AwaitQueue = require('awaitqueue'); const AwaitQueue = require('awaitqueue');
const Logger = require('./lib/Logger'); const Logger = require('./lib/Logger');
const Room = require('./lib/Room'); const Room = require('./lib/Room');
const utils = require('./util'); const Peer = require('./lib/Peer');
const base64 = require('base-64'); const base64 = require('base-64');
const helmet = require('helmet');
const {
loginHelper,
logoutHelper
} = require('./httpHelper');
// auth // auth
const passport = require('passport'); const passport = require('passport');
const redis = require('redis');
const client = redis.createClient();
const { Issuer, Strategy } = require('openid-client'); const { Issuer, Strategy } = require('openid-client');
const session = require('express-session'); const expressSession = require('express-session');
const RedisStore = require('connect-redis')(expressSession);
const sharedSession = require('express-socket.io-session');
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log('- process.env.DEBUG:', process.env.DEBUG); console.log('- process.env.DEBUG:', process.env.DEBUG);
@ -42,17 +51,51 @@ let nextMediasoupWorkerIdx = 0;
// Map of Room instances indexed by roomId. // Map of Room instances indexed by roomId.
const rooms = new Map(); const rooms = new Map();
// Map of Peer instances indexed by peerId.
const peers = new Map();
// TLS server configuration. // TLS server configuration.
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),
secureOptions : 'tlsv12',
ciphers :
[
'ECDHE-ECDSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES128-GCM-SHA256',
'ECDHE-ECDSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES256-GCM-SHA384',
'ECDHE-ECDSA-CHACHA20-POLY1305',
'ECDHE-RSA-CHACHA20-POLY1305',
'DHE-RSA-AES128-GCM-SHA256',
'DHE-RSA-AES256-GCM-SHA384'
].join(':'),
honorCipherOrder : true
}; };
const app = express(); const app = express();
let httpsServer;
let oidcClient; app.use(helmet.hsts());
let oidcStrategy;
app.use(cookieParser());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
const session = expressSession({
secret : config.cookieSecret,
name : config.cookieName,
resave : true,
saveUninitialized : true,
store : new RedisStore({ client }),
cookie : {
secure : true,
httpOnly : true,
maxAge : 60 * 60 * 1000 // Expire after 1 hour since last request from user
}
});
app.use(session);
passport.serializeUser((user, done) => passport.serializeUser((user, done) =>
{ {
@ -64,6 +107,11 @@ passport.deserializeUser((user, done) =>
done(null, user); done(null, user);
}); });
let httpsServer;
let io;
let oidcClient;
let oidcStrategy;
const auth = config.auth; const auth = config.auth;
async function run() async function run()
@ -74,7 +122,7 @@ async function run()
typeof(auth.clientOptions) !== 'undefined' typeof(auth.clientOptions) !== 'undefined'
) )
{ {
Issuer.discover(auth.issuerURL).then( async (oidcIssuer) => Issuer.discover(auth.issuerURL).then(async (oidcIssuer) =>
{ {
// Setup authentication // Setup authentication
await setupAuth(oidcIssuer); await setupAuth(oidcIssuer);
@ -115,6 +163,15 @@ async function run()
room.logStatus(); room.logStatus();
} }
}, 120000); }, 120000);
// check for deserted rooms
setInterval(() =>
{
for (const room of rooms.values())
{
room.checkEmpty();
}
}, 10000);
} }
async function setupAuth(oidcIssuer) async function setupAuth(oidcIssuer)
@ -136,10 +193,9 @@ async function setupAuth(oidcIssuer)
// resolved from the issuer configuration, instead of true you may provide // resolved from the issuer configuration, instead of true you may provide
// any of the supported values directly, i.e. "S256" (recommended) or "plain" // any of the supported values directly, i.e. "S256" (recommended) or "plain"
const usePKCE = false; const usePKCE = false;
const client = oidcClient;
oidcStrategy = new Strategy( oidcStrategy = new Strategy(
{ client, params, passReqToCallback, usePKCE }, { client: oidcClient, params, passReqToCallback, usePKCE },
(tokenset, userinfo, done) => (tokenset, userinfo, done) =>
{ {
const user = const user =
@ -150,15 +206,15 @@ async function setupAuth(oidcIssuer)
_claims : tokenset.claims _claims : tokenset.claims
}; };
if (typeof(userinfo.picture) !== 'undefined') if (userinfo.picture != null)
{ {
if (!userinfo.picture.match(/^http/g)) if (!userinfo.picture.match(/^http/g))
{ {
user.Photos = [ { value: `data:image/jpeg;base64, ${userinfo.picture}` } ]; user.picture = `data:image/jpeg;base64, ${userinfo.picture}`;
} }
else else
{ {
user.Photos = [ { value: userinfo.picture } ]; user.picture = userinfo.picture;
} }
} }
@ -174,22 +230,22 @@ async function setupAuth(oidcIssuer)
if (userinfo.email != null) if (userinfo.email != null)
{ {
user.emails = [ { value: userinfo.email } ]; user.email = userinfo.email;
} }
if (userinfo.given_name != null) if (userinfo.given_name != null)
{ {
user.name = { givenName: userinfo.given_name }; user.name.givenName = userinfo.given_name;
} }
if (userinfo.family_name != null) if (userinfo.family_name != null)
{ {
user.name = { familyName: userinfo.family_name }; user.name.familyName = userinfo.family_name;
} }
if (userinfo.middle_name != null) if (userinfo.middle_name != null)
{ {
user.name = { middleName: userinfo.middle_name }; user.name.middleName = userinfo.middle_name;
} }
return done(null, user); return done(null, user);
@ -198,24 +254,15 @@ async function setupAuth(oidcIssuer)
passport.use('oidc', oidcStrategy); passport.use('oidc', oidcStrategy);
app.use(session({
secret : config.cookieSecret,
resave : true,
saveUninitialized : true,
cookie : { secure: true }
}));
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());
// login // loginparams
app.get('/auth/login', (req, res, next) => app.get('/auth/login', (req, res, next) =>
{ {
passport.authenticate('oidc', { passport.authenticate('oidc', {
state : base64.encode(JSON.stringify({ state : base64.encode(JSON.stringify({
roomId : req.query.roomId, id : req.query.id
peerId : req.query.peerId,
code : utils.random(10)
})) }))
})(req, res, next); })(req, res, next);
}); });
@ -224,7 +271,7 @@ async function setupAuth(oidcIssuer)
app.get('/auth/logout', (req, res) => app.get('/auth/logout', (req, res) =>
{ {
req.logout(); req.logout();
res.redirect('/'); res.send(logoutHelper());
}); });
// callback // callback
@ -235,10 +282,8 @@ async function setupAuth(oidcIssuer)
{ {
const state = JSON.parse(base64.decode(req.query.state)); const state = JSON.parse(base64.decode(req.query.state));
if (rooms.has(state.roomId))
{
let displayName; let displayName;
let photo; let picture;
if (req.user != null) if (req.user != null)
{ {
@ -247,29 +292,22 @@ async function setupAuth(oidcIssuer)
else else
displayName = ''; displayName = '';
if ( if (req.user.picture != null)
req.user.Photos != null && picture = req.user.picture;
req.user.Photos[0] != null &&
req.user.Photos[0].value != null
)
photo = req.user.Photos[0].value;
else else
photo = '/static/media/buddy.403cb9f6.svg'; picture = '/static/media/buddy.403cb9f6.svg';
} }
const data = const peer = peers.get(state.id);
{
peerId : state.peerId,
displayName : displayName,
picture : photo
};
const room = rooms.get(state.roomId); peer && (peer.authenticated = true);
peer && (peer.displayName = displayName);
peer && (peer.picture = picture);
room.authCallback(data); res.send(loginHelper({
} displayName,
picture
res.send(''); }));
} }
); );
} }
@ -316,11 +354,17 @@ async function runHttpsServer()
} }
/** /**
* Create a protoo WebSocketServer to allow WebSocket connections from browsers. * Create a WebSocketServer to allow WebSocket connections from browsers.
*/ */
async function runWebSocketServer() async function runWebSocketServer()
{ {
const io = require('socket.io')(httpsServer); io = require('socket.io')(httpsServer);
io.use(
sharedSession(session, {
autoSave : true
})
);
// Handle connections from clients. // Handle connections from clients.
io.on('connection', (socket) => io.on('connection', (socket) =>
@ -342,12 +386,17 @@ async function runWebSocketServer()
queue.push(async () => queue.push(async () =>
{ {
const room = await getOrCreateRoom({ roomId }); const room = await getOrCreateRoom({ roomId });
const peer = new Peer({ id: peerId, socket });
room.handleConnection({ peerId, socket }); peers.set(peerId, peer);
peer.on('close', () => peers.delete(peerId));
room.handlePeer(peer);
}) })
.catch((error) => .catch((error) =>
{ {
logger.error('room creation or room joining failed:%o', error); logger.error('room creation or room joining failed [error:"%o"]', error);
socket.disconnect(true); socket.disconnect(true);
@ -410,7 +459,7 @@ async function getOrCreateRoom({ roomId })
// If the Room does not exist create a new one. // If the Room does not exist create a new one.
if (!room) if (!room)
{ {
logger.info('creating a new Room [roomId:%s]', roomId); logger.info('creating a new Room [roomId:"%s"]', roomId);
const mediasoupWorker = getMediasoupWorker(); const mediasoupWorker = getMediasoupWorker();

View File

@ -1,18 +0,0 @@
'use strict';
var crypto = require('crypto');
exports.random = function (howMany, chars) {
chars = chars
|| "abcdefghijklmnopqrstuwxyzABCDEFGHIJKLMNOPQRSTUWXYZ0123456789";
var rnd = crypto.randomBytes(howMany)
, value = new Array(howMany)
, len = len = Math.min(256, chars.length)
, d = 256 / len
for (var i = 0; i < howMany; i++) {
value[i] = chars[Math.floor(rnd[i] / d)]
};
return value.join('');
}