Merge pull request #7101 from jfsiebel/network-indicator

Implement audio quality bars
This commit is contained in:
Anton Georgiev 2019-05-24 16:12:20 -04:00 committed by GitHub
commit 236c62700e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 654 additions and 24 deletions

View File

@ -13,6 +13,7 @@ import clearPresentationPods from '/imports/api/presentation-pods/server/modifie
import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers'; import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers';
import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo'; import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo';
import clearNote from '/imports/api/note/server/modifiers/clearNote'; import clearNote from '/imports/api/note/server/modifiers/clearNote';
import clearNetworkInformation from '/imports/api/network-information/server/modifiers/clearNetworkInformation';
export default function meetingHasEnded(meetingId) { export default function meetingHasEnded(meetingId) {
return Meetings.remove({ meetingId }, () => { return Meetings.remove({ meetingId }, () => {
@ -28,6 +29,7 @@ export default function meetingHasEnded(meetingId) {
clearVoiceUsers(meetingId); clearVoiceUsers(meetingId);
clearUserInfo(meetingId); clearUserInfo(meetingId);
clearNote(meetingId); clearNote(meetingId);
clearNetworkInformation(meetingId);
return Logger.info(`Cleared Meetings with id ${meetingId}`); return Logger.info(`Cleared Meetings with id ${meetingId}`);
}); });

View File

@ -0,0 +1,9 @@
import { Meteor } from 'meteor/meteor';
const NetworkInformation = new Mongo.Collection('network-information');
if (Meteor.isServer) {
NetworkInformation._ensureIndex({ meetingId: 1 });
}
export default NetworkInformation;

View File

@ -0,0 +1,2 @@
import './methods';
import './publisher';

View File

@ -0,0 +1,6 @@
import { Meteor } from 'meteor/meteor';
import userInstabilityDetected from './methods/userInstabilityDetected';
Meteor.methods({
userInstabilityDetected,
});

View File

@ -0,0 +1,22 @@
import { check } from 'meteor/check';
import NetworkInformation from '/imports/api/network-information';
import Logger from '/imports/startup/server/logger';
export default function userInstabilityDetected(credentials, sender) {
const { meetingId, requesterUserId: receiver } = credentials;
check(meetingId, String);
check(receiver, String);
check(sender, String);
const payload = {
time: new Date().getTime(),
meetingId,
receiver,
sender,
};
Logger.debug(`Receiver ${receiver} reported a network instability in meeting ${meetingId}`);
return NetworkInformation.insert(payload);
}

View File

@ -0,0 +1,14 @@
import NetworkInformation from '/imports/api/network-information';
import Logger from '/imports/startup/server/logger';
export default function clearNetworkInformation(meetingId) {
if (meetingId) {
return NetworkInformation.remove({ meetingId }, () => {
Logger.info(`Cleared Network Information (${meetingId})`);
});
}
return NetworkInformation.remove({}, () => {
Logger.info('Cleared Network Information (all)');
});
}

View File

@ -0,0 +1,21 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import NetworkInformation from '/imports/api/network-information';
function networkInformation(credentials) {
const { meetingId } = credentials;
check(meetingId, String);
return NetworkInformation.find({
meetingId,
});
}
function publish(...args) {
const boundNetworkInformation = networkInformation.bind(this);
return boundNetworkInformation(...args);
}
Meteor.publish('network-information', publish);

View File

@ -5,7 +5,9 @@ import assignPresenter from './methods/assignPresenter';
import changeRole from './methods/changeRole'; import changeRole from './methods/changeRole';
import removeUser from './methods/removeUser'; import removeUser from './methods/removeUser';
import toggleUserLock from './methods/toggleUserLock'; import toggleUserLock from './methods/toggleUserLock';
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
import userActivitySign from './methods/userActivitySign'; import userActivitySign from './methods/userActivitySign';
import userChangedSettings from './methods/userChangedSettings';
import userLeftMeeting from './methods/userLeftMeeting'; import userLeftMeeting from './methods/userLeftMeeting';
Meteor.methods({ Meteor.methods({
@ -15,6 +17,8 @@ Meteor.methods({
removeUser, removeUser,
validateAuthToken, validateAuthToken,
toggleUserLock, toggleUserLock,
setUserEffectiveConnectionType,
userActivitySign, userActivitySign,
userChangedSettings,
userLeftMeeting, userLeftMeeting,
}); });

View File

@ -0,0 +1,29 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import setEffectiveConnectionType from '../modifiers/setUserEffectiveConnectionType';
export default function setUserEffectiveConnectionType(credentials, effectiveConnectionType) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'ChangeUserEffectiveConnectionMsg';
const { meetingId, requesterUserId } = credentials;
check(meetingId, String);
check(requesterUserId, String);
check(effectiveConnectionType, String);
const payload = {
meetingId,
userId: requesterUserId,
effectiveConnectionType,
};
setEffectiveConnectionType(meetingId, requesterUserId, effectiveConnectionType);
Logger.verbose(`User ${requesterUserId} effective connection updated to ${effectiveConnectionType}`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
import setChangedSettings from '../modifiers/setChangedSettings';
export default function userChangedSettings(credentials, setting, value) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'UserChangedSettingsEvtMsg';
const { meetingId, requesterUserId } = credentials;
if (!meetingId || !requesterUserId) return;
check(meetingId, String);
check(requesterUserId, String);
check(setting, String);
check(value, Match.Any);
const payload = {
meetingId,
requesterUserId,
setting,
value,
};
setChangedSettings(requesterUserId, setting, value);
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -80,6 +80,7 @@ export default function addUser(meetingId, user) {
isBreakoutUser: Meeting.meetingProp.isBreakout, isBreakoutUser: Meeting.meetingProp.isBreakout,
parentId: Meeting.breakoutProps.parentId, parentId: Meeting.breakoutProps.parentId,
}, },
effectiveConnectionType: null,
inactivityCheck: false, inactivityCheck: false,
responseDelay: 0, responseDelay: 0,
loggedOut: false, loggedOut: false,

View File

@ -0,0 +1,31 @@
import { check } from 'meteor/check';
import Users from '/imports/api/users';
import Logger from '/imports/startup/server/logger';
export default function setChangedSettings(userId, setting, value) {
check(userId, String);
check(setting, String);
check(value, Match.Any);
const selector = {
userId,
};
const modifier = {
$set: {},
};
modifier.$set[setting] = value;
const cb = (err, numChanged) => {
if (err) {
Logger.error(`${err}`);
}
if (numChanged) {
Logger.info(`Updated setting ${setting} to ${value} for user ${userId}`);
}
};
return Users.update(selector, modifier, cb);
}

View File

@ -0,0 +1,33 @@
import { check } from 'meteor/check';
import Users from '/imports/api/users';
import Logger from '/imports/startup/server/logger';
export default function setUserEffectiveConnectionType(meetingId, userId, effectiveConnectionType) {
check(meetingId, String);
check(userId, String);
check(effectiveConnectionType, String);
const selector = {
meetingId,
userId,
effectiveConnectionType: { $ne: effectiveConnectionType },
};
const modifier = {
$set: {
effectiveConnectionType,
},
};
const cb = (err, numChanged) => {
if (err) {
Logger.error(`Updating user ${userId}: ${err}`);
}
if (numChanged) {
Logger.info(`Updated user ${userId} effective connection to ${effectiveConnectionType} in meeting ${meetingId}`);
}
};
return Users.update(selector, modifier, cb);
}

View File

@ -16,6 +16,7 @@ import AudioContainer from '../audio/container';
import ChatAlertContainer from '../chat/alert/container'; import ChatAlertContainer from '../chat/alert/container';
import BannerBarContainer from '/imports/ui/components/banner-bar/container'; import BannerBarContainer from '/imports/ui/components/banner-bar/container';
import WaitingNotifierContainer from '/imports/ui/components/waiting-users/alert/container'; import WaitingNotifierContainer from '/imports/ui/components/waiting-users/alert/container';
import { startBandwidthMonitoring, updateNavigatorConnection } from '/imports/ui/services/network-information/index';
import LockNotifier from '/imports/ui/components/lock-viewers/notify/container'; import LockNotifier from '/imports/ui/components/lock-viewers/notify/container';
import { styles } from './styles'; import { styles } from './styles';
@ -24,6 +25,7 @@ const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
const APP_CONFIG = Meteor.settings.public.app; const APP_CONFIG = Meteor.settings.public.app;
const DESKTOP_FONT_SIZE = APP_CONFIG.desktopFontSize; const DESKTOP_FONT_SIZE = APP_CONFIG.desktopFontSize;
const MOBILE_FONT_SIZE = APP_CONFIG.mobileFontSize; const MOBILE_FONT_SIZE = APP_CONFIG.mobileFontSize;
const ENABLE_NETWORK_INFORMATION = APP_CONFIG.enableNetworkInformation;
const intlMessages = defineMessages({ const intlMessages = defineMessages({
userListLabel: { userListLabel: {
@ -110,11 +112,22 @@ class App extends Component {
this.handleWindowResize(); this.handleWindowResize();
window.addEventListener('resize', this.handleWindowResize, false); window.addEventListener('resize', this.handleWindowResize, false);
if (ENABLE_NETWORK_INFORMATION) {
if (navigator.connection) {
this.handleNetworkConnection();
navigator.connection.addEventListener('change', this.handleNetworkConnection);
}
startBandwidthMonitoring();
}
logger.info({ logCode: 'app_component_componentdidmount' }, 'Client loaded successfully'); logger.info({ logCode: 'app_component_componentdidmount' }, 'Client loaded successfully');
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.handleWindowResize, false); window.removeEventListener('resize', this.handleWindowResize, false);
navigator.connection.addEventListener('change', this.handleNetworkConnection, false);
} }
handleWindowResize() { handleWindowResize() {
@ -125,6 +138,10 @@ class App extends Component {
this.setState({ enableResize: shouldEnableResize }); this.setState({ enableResize: shouldEnableResize });
} }
handleNetworkConnection() {
updateNavigatorConnection(navigator.connection);
}
renderPanel() { renderPanel() {
const { enableResize } = this.state; const { enableResize } = this.state;
const { openPanel } = this.props; const { openPanel } = this.props;

View File

@ -5,8 +5,10 @@ import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash'; import _ from 'lodash';
import Auth from '/imports/ui/services/auth'; import Auth from '/imports/ui/services/auth';
import Meetings from '/imports/api/meetings'; import Meetings from '/imports/api/meetings';
import NavBarService from '../nav-bar/service'; import Users from '/imports/api/users';
import BreakoutRemainingTime from '/imports/ui/components/breakout-room/breakout-remaining-time/container'; import BreakoutRemainingTime from '/imports/ui/components/breakout-room/breakout-remaining-time/container';
import SlowConnection from '/imports/ui/components/slow-connection/component';
import NavBarService from '../nav-bar/service';
import NotificationsBar from './component'; import NotificationsBar from './component';
@ -19,6 +21,14 @@ const STATUS_FAILED = 'failed';
// failed to connect and waiting to try to reconnect // failed to connect and waiting to try to reconnect
const STATUS_WAITING = 'waiting'; const STATUS_WAITING = 'waiting';
const METEOR_SETTINGS_APP = Meteor.settings.public.app;
// https://github.com/bigbluebutton/bigbluebutton/issues/5286#issuecomment-465342716
const SLOW_CONNECTIONS_TYPES = METEOR_SETTINGS_APP.effectiveConnection;
const ENABLE_NETWORK_INFORMATION = METEOR_SETTINGS_APP.enableNetworkInformation;
const HELP_LINK = METEOR_SETTINGS_APP.helpLink;
const intlMessages = defineMessages({ const intlMessages = defineMessages({
failedMessage: { failedMessage: {
id: 'app.failedMessage', id: 'app.failedMessage',
@ -60,6 +70,14 @@ const intlMessages = defineMessages({
id: 'app.meeting.alertBreakoutEndsUnderOneMinute', id: 'app.meeting.alertBreakoutEndsUnderOneMinute',
description: 'Alert that tells that the breakout end under a minute', description: 'Alert that tells that the breakout end under a minute',
}, },
slowEffectiveConnectionDetected: {
id: 'app.network.connection.effective.slow',
description: 'Alert for detected slow connections',
},
slowEffectiveConnectionHelpLink: {
id: 'app.network.connection.effective.slow.help',
description: 'Help link for slow connections',
},
}); });
const NotificationsBarContainer = (props) => { const NotificationsBarContainer = (props) => {
@ -104,6 +122,22 @@ export default injectIntl(withTracker(({ intl }) => {
const { status, connected, retryTime } = Meteor.status(); const { status, connected, retryTime } = Meteor.status();
const data = {}; const data = {};
const user = Users.findOne({ userId: Auth.userID });
if (user) {
const { effectiveConnectionType } = user;
if (ENABLE_NETWORK_INFORMATION && SLOW_CONNECTIONS_TYPES.includes(effectiveConnectionType)) {
data.message = (
<SlowConnection effectiveConnectionType={effectiveConnectionType}>
{intl.formatMessage(intlMessages.slowEffectiveConnectionDetected)}
<a href={HELP_LINK} target="_blank" rel="noopener noreferrer">
{intl.formatMessage(intlMessages.slowEffectiveConnectionHelpLink)}
</a>
</SlowConnection>
);
}
}
if (!connected) { if (!connected) {
data.color = 'primary'; data.color = 'primary';
switch (status) { switch (status) {

View File

@ -0,0 +1,32 @@
import React, { Fragment } from 'react';
import cx from 'classnames';
import { styles } from './styles';
const SLOW_CONNECTIONS_TYPES = {
critical: {
level: styles.bad,
bars: styles.oneBar,
},
danger: {
level: styles.bad,
bars: styles.twoBars,
},
warning: {
level: styles.warning,
bars: styles.threeBars,
},
};
const SlowConnection = ({ children, effectiveConnectionType, iconOnly }) => (
<Fragment>
<div className={cx(styles.signalBars, styles.sizingBox, SLOW_CONNECTIONS_TYPES[effectiveConnectionType].level, SLOW_CONNECTIONS_TYPES[effectiveConnectionType].bars)}>
<div className={cx(styles.firstBar, styles.bar)} />
<div className={cx(styles.secondBar, styles.bar)} />
<div className={cx(styles.thirdBar, styles.bar)} />
<div className={cx(styles.fourthBar, styles.bar)} />
</div>
{!iconOnly ? (<span>{children}</span>) : null}
</Fragment>
);
export default SlowConnection;

View File

@ -0,0 +1,34 @@
.sizingBox {
width: 27px;
height: 15px;
box-sizing: border-box;
}
.signalBars {
display: inline-block;
}
.signalBars .bar {
width: 4px;
margin-left: 1px;
display: inline-block;
}
.signalBars .bar.firstBar { height: 25%; }
.signalBars .bar.secondBar { height: 50%; }
.signalBars .bar.thirdBar { height: 75%; }
.signalBars .bar.fourthBar { height: 100%; }
.bad .bar {
background-color: #e74c3c;
}
.warning .bar {
background-color: #f1c40f;
}
.fourBars .bar.fifthBar,
.threeBars .bar.fifthBar,
.threeBars .bar.fourthBar,
.oneBar .bar:not(.firstBar),
.twoBars .bar:not(.firstBar):not(.secondBar) {
background-color: #c3c3c3;
}

View File

@ -16,6 +16,7 @@ const SUBSCRIPTIONS = [
'users', 'meetings', 'polls', 'presentations', 'slides', 'captions', 'users', 'meetings', 'polls', 'presentations', 'slides', 'captions',
'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat', 'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat',
'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'note', 'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'note',
'network-information',
]; ];
class Subscriptions extends Component { class Subscriptions extends Component {

View File

@ -490,6 +490,7 @@ class UserDropdown extends PureComponent {
render() { render() {
const { const {
compact, compact,
currentUser,
user, user,
intl, intl,
isMeetingLocked, isMeetingLocked,
@ -508,6 +509,8 @@ class UserDropdown extends PureComponent {
const userItemContentsStyle = {}; const userItemContentsStyle = {};
const { isModerator } = currentUser;
userItemContentsStyle[styles.dropdown] = true; userItemContentsStyle[styles.dropdown] = true;
userItemContentsStyle[styles.userListItem] = !isActionsOpen; userItemContentsStyle[styles.userListItem] = !isActionsOpen;
userItemContentsStyle[styles.usertListItemWithMenu] = isActionsOpen; userItemContentsStyle[styles.usertListItemWithMenu] = isActionsOpen;
@ -551,7 +554,7 @@ class UserDropdown extends PureComponent {
{<UserIcons {<UserIcons
{...{ {...{
user, user,
compact, isModerator,
}} }}
/>} />}
</div> </div>

View File

@ -1,9 +1,16 @@
import React from 'react'; import React, { memo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Icon from '/imports/ui/components/icon/component'; import SlowConnection from '/imports/ui/components/slow-connection/component';
import Auth from '/imports/ui/services/auth';
import { styles } from './styles'; import { styles } from './styles';
const METEOR_SETTINGS_APP = Meteor.settings.public.app;
const SLOW_CONNECTIONS_TYPES = METEOR_SETTINGS_APP.effectiveConnection;
const ENABLE_NETWORK_INFORMATION = METEOR_SETTINGS_APP.enableNetworkInformation;
const propTypes = { const propTypes = {
isModerator: PropTypes.bool.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
isPresenter: PropTypes.bool.isRequired, isPresenter: PropTypes.bool.isRequired,
@ -11,31 +18,33 @@ const propTypes = {
isModerator: PropTypes.bool.isRequired, isModerator: PropTypes.bool.isRequired,
image: PropTypes.string, image: PropTypes.string,
}).isRequired, }).isRequired,
compact: PropTypes.bool.isRequired,
}; };
const UserIcons = (props) => { const UserIcons = (props) => {
const { const {
user, isModerator,
compact, user: {
effectiveConnectionType,
id,
},
} = props; } = props;
if (compact || user.isSharingWebcam) { const showNetworkInformation = ENABLE_NETWORK_INFORMATION
return null; && SLOW_CONNECTIONS_TYPES.includes(effectiveConnectionType)
} && (id === Auth.userID || isModerator);
return ( return (
<div className={styles.userIcons}> <div className={styles.userIcons}>
{ {
user.isSharingWebcam ? showNetworkInformation ? (
<span className={styles.userIconsContainer}> <span className={styles.userIconsContainer}>
<Icon iconName="video" /> <SlowConnection effectiveConnectionType={effectiveConnectionType} iconOnly />
</span> </span>
: null ) : null
} }
</div> </div>
); );
}; };
UserIcons.propTypes = propTypes; UserIcons.propTypes = propTypes;
export default UserIcons; export default memo(UserIcons);

View File

@ -22,7 +22,7 @@
justify-content: space-between; justify-content: space-between;
margin: 0 0 0 var(--sm-padding-x)/2; margin: 0 0 0 var(--sm-padding-x)/2;
text-align: right; text-align: right;
font-size: 1rem; font-size: 1.25rem;
flex-shrink: 1; flex-shrink: 1;
color: var(--user-icons-color); color: var(--user-icons-color);

View File

@ -7,13 +7,21 @@ import { fetchWebRTCMappedStunTurnServers } from '/imports/utils/fetchStunTurnSe
import ReconnectingWebSocket from 'reconnecting-websocket'; import ReconnectingWebSocket from 'reconnecting-websocket';
import logger from '/imports/startup/client/logger'; import logger from '/imports/startup/client/logger';
import browser from 'browser-detect'; import browser from 'browser-detect';
import {
updateCurrentWebcamsConnection,
getCurrentWebcams,
deleteWebcamConnection,
newWebcamConnection,
updateWebcamStats,
} from '/imports/ui/services/network-information/index';
import { tryGenerateIceCandidates } from '../../../utils/safari-webrtc'; import { tryGenerateIceCandidates } from '../../../utils/safari-webrtc';
import Auth from '/imports/ui/services/auth'; import Auth from '/imports/ui/services/auth';
import VideoService from './service'; import VideoService from './service';
import VideoList from './video-list/component'; import VideoList from './video-list/component';
// const VIDEO_CONSTRAINTS = Meteor.settings.public.kurento.cameraConstraints; const APP_CONFIG = Meteor.settings.public.app;
const ENABLE_NETWORK_INFORMATION = APP_CONFIG.enableNetworkInformation;
const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles; const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles;
const intlClientErrors = defineMessages({ const intlClientErrors = defineMessages({
@ -158,6 +166,30 @@ class VideoProvider extends Component {
this.visibility.onVisible(this.unpauseViewers); this.visibility.onVisible(this.unpauseViewers);
this.visibility.onHidden(this.pauseViewers); this.visibility.onHidden(this.pauseViewers);
if (ENABLE_NETWORK_INFORMATION) {
this.currentWebcamsStatsInterval = setInterval(() => {
const currentWebcams = getCurrentWebcams();
if (!currentWebcams) return;
const { payload } = currentWebcams;
payload.forEach((id) => {
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true
&& peer.peerConnection.getLocalStreams().length > 0;
const hasRemoteStream = peer && peer.started === true
&& peer.peerConnection.getRemoteStreams().length > 0;
if (hasLocalStream) {
this.customGetStats(peer.peerConnection, peer.peerConnection.getLocalStreams()[0].getVideoTracks()[0], (stats => updateWebcamStats(id, stats)), true);
} else if (hasRemoteStream) {
this.customGetStats(peer.peerConnection, peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0], (stats => updateWebcamStats(id, stats)), true);
}
});
}, 5000);
}
} }
componentWillUpdate({ users, userId }) { componentWillUpdate({ users, userId }) {
@ -196,6 +228,8 @@ class VideoProvider extends Component {
this.stopWebRTCPeer(id); this.stopWebRTCPeer(id);
}); });
clearInterval(this.currentWebcamsStatsInterval);
// Close websocket connection to prevent multiple reconnects from happening // Close websocket connection to prevent multiple reconnects from happening
this.ws.close(); this.ws.close();
} }
@ -441,6 +475,10 @@ class VideoProvider extends Component {
webRtcPeer.dispose(); webRtcPeer.dispose();
} }
delete this.webRtcPeers[id]; delete this.webRtcPeers[id];
if (ENABLE_NETWORK_INFORMATION) {
deleteWebcamConnection(id);
updateCurrentWebcamsConnection(this.webRtcPeers);
}
} else { } else {
this.logger('warn', 'No WebRTC peer to stop (not an error)', 'video_provider_no_peer_to_destroy', { cameraId: id }); this.logger('warn', 'No WebRTC peer to stop (not an error)', 'video_provider_no_peer_to_destroy', { cameraId: id });
} }
@ -527,6 +565,10 @@ class VideoProvider extends Component {
if (this.webRtcPeers[id].peerConnection) { if (this.webRtcPeers[id].peerConnection) {
this.webRtcPeers[id].peerConnection.oniceconnectionstatechange = this._getOnIceConnectionStateChangeCallback(id); this.webRtcPeers[id].peerConnection.oniceconnectionstatechange = this._getOnIceConnectionStateChangeCallback(id);
} }
if (ENABLE_NETWORK_INFORMATION) {
newWebcamConnection(id);
updateCurrentWebcamsConnection(this.webRtcPeers);
}
} }
} }
@ -680,7 +722,7 @@ class VideoProvider extends Component {
} }
} }
customGetStats(peer, mediaStreamTrack, callback) { customGetStats(peer, mediaStreamTrack, callback, monitoring = false) {
const { stats } = this.state; const { stats } = this.state;
const statsState = stats; const statsState = stats;
let promise; let promise;
@ -735,6 +777,7 @@ class VideoProvider extends Component {
encodeUsagePercent: videoInOrOutbound.encodeUsagePercent, encodeUsagePercent: videoInOrOutbound.encodeUsagePercent,
rtt: videoInOrOutbound.rtt, rtt: videoInOrOutbound.rtt,
currentDelay: videoInOrOutbound.currentDelay, currentDelay: videoInOrOutbound.currentDelay,
pliCount: videoInOrOutbound.pliCount,
}; };
const videoStatsArray = statsState; const videoStatsArray = statsState;
@ -742,7 +785,10 @@ class VideoProvider extends Component {
while (videoStatsArray.length > 5) { // maximum interval to consider while (videoStatsArray.length > 5) { // maximum interval to consider
videoStatsArray.shift(); videoStatsArray.shift();
} }
this.setState({ stats: videoStatsArray });
if (!monitoring) {
this.setState({ stats: videoStatsArray });
}
const firstVideoStats = videoStatsArray[0]; const firstVideoStats = videoStatsArray[0];
const lastVideoStats = videoStatsArray[videoStatsArray.length - 1]; const lastVideoStats = videoStatsArray[videoStatsArray.length - 1];
@ -767,9 +813,9 @@ class VideoProvider extends Component {
let videoBitrate; let videoBitrate;
if (videoStats.packetsReceived > 0) { // Remote video if (videoStats.packetsReceived > 0) { // Remote video
videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived) * 100)) || 0).toFixed(1); videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived)) * 100) || 0).toFixed(1);
videoBitrate = Math.floor(videoKbitsReceivedPerSecond || 0); videoBitrate = Math.floor(videoKbitsReceivedPerSecond || 0);
videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived) * 100)) || 0).toFixed(1); videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived)) * 100) || 0).toFixed(1);
} else { } else {
videoLostPercentage = (((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent)) * 100) || 0).toFixed(1); videoLostPercentage = (((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent)) * 100) || 0).toFixed(1);
videoBitrate = Math.floor(videoKbitsSentPerSecond || 0); videoBitrate = Math.floor(videoKbitsSentPerSecond || 0);
@ -793,6 +839,7 @@ class VideoProvider extends Component {
encodeUsagePercent: videoStats.encodeUsagePercent, encodeUsagePercent: videoStats.encodeUsagePercent,
rtt: videoStats.rtt, rtt: videoStats.rtt,
currentDelay: videoStats.currentDelay, currentDelay: videoStats.currentDelay,
pliCount: videoStats.pliCount,
}, },
}; };

View File

@ -0,0 +1,231 @@
import NetworkInformation from '/imports/api/network-information';
import { makeCall } from '/imports/ui/services/api';
import Auth from '/imports/ui/services/auth';
import Users from '/imports/api/users';
import logger from '/imports/startup/client/logger';
import _ from 'lodash';
const NetworkInformationLocal = new Mongo.Collection(null);
const NAVIGATOR_CONNECTION = 'NAVIGATOR_CONNECTION';
const NUMBER_OF_WEBCAMS_CHANGED = 'NUMBER_OF_WEBCAMS_CHANGED';
const STARTED_WEBCAM_SHARING = 'STARTED_WEBCAM_SHARING';
const STOPPED_WEBCAM_SHARING = 'STOPPED_WEBCAM_SHARING';
const WEBCAMS_GET_STATUS = 'WEBCAMS_GET_STATUS';
const DANGER_BEGIN_TIME = 5000;
const DANGER_END_TIME = 30000;
const WARNING_END_TIME = 60000;
let monitoringIntervalRef;
export const updateCurrentWebcamsConnection = (connections) => {
const doc = {
timestamp: new Date().getTime(),
event: NUMBER_OF_WEBCAMS_CHANGED,
payload: Object.keys(connections),
};
NetworkInformationLocal.insert(doc);
};
export const deleteWebcamConnection = (id) => {
const doc = {
timestamp: new Date().getTime(),
event: STOPPED_WEBCAM_SHARING,
payload: { id },
};
NetworkInformationLocal.insert(doc);
};
export const getCurrentWebcams = () => NetworkInformationLocal
.findOne({
event: NUMBER_OF_WEBCAMS_CHANGED,
}, { sort: { timestamp: -1 } });
export const newWebcamConnection = (id) => {
const doc = {
timestamp: new Date().getTime(),
event: STARTED_WEBCAM_SHARING,
payload: { id },
};
NetworkInformationLocal.insert(doc);
};
export const startBandwidthMonitoring = () => {
monitoringIntervalRef = setInterval(() => {
const monitoringTime = new Date().getTime();
const dangerLowerBoundary = monitoringTime - DANGER_BEGIN_TIME;
const warningLowerBoundary = monitoringTime - DANGER_END_TIME;
const warningUpperBoundary = monitoringTime - WARNING_END_TIME;
// Remove old documents to reduce the size of the local collection.
NetworkInformationLocal.remove({
event: WEBCAMS_GET_STATUS,
timestamp: { $lt: warningUpperBoundary },
});
const usersWatchingWebcams = Users.find({
userId: { $ne: Auth.userID },
viewParticipantsWebcams: true,
connectionStatus: 'online',
}).map(user => user.userId);
const warningZone = NetworkInformationLocal
.find({
event: WEBCAMS_GET_STATUS,
timestamp: { $lte: warningLowerBoundary, $gt: warningUpperBoundary },
$or: [
{
'payload.id': Auth.userID,
'payload.stats.deltaPliCount': { $gt: 0 },
},
{
'payload.id': { $ne: Auth.userID },
'payload.stats.deltaPacketsLost': { $gt: 0 },
},
],
}).count();
const warningZoneReceivers = NetworkInformation
.find({
receiver: { $in: usersWatchingWebcams },
sender: Auth.userID,
time: { $lte: warningLowerBoundary, $gt: warningUpperBoundary },
}).count();
const dangerZone = _.uniqBy(NetworkInformationLocal
.find({
event: WEBCAMS_GET_STATUS,
timestamp: { $lt: dangerLowerBoundary, $gte: warningLowerBoundary },
$or: [
{
'payload.id': Auth.userID,
'payload.stats.deltaPliCount': { $gt: 0 },
},
{
'payload.id': { $ne: Auth.userID },
'payload.stats.deltaPacketsLost': { $gt: 0 },
},
],
}).fetch(), 'payload.id').length;
const dangerZoneReceivers = _.uniqBy(NetworkInformation
.find({
receiver: { $in: usersWatchingWebcams },
sender: Auth.userID,
time: { $lt: dangerLowerBoundary, $gte: warningLowerBoundary },
}).fetch(), 'receiver').length;
let effectiveType = 'good';
if (dangerZone) {
if (!dangerZoneReceivers) {
effectiveType = 'danger';
}
if (dangerZoneReceivers === usersWatchingWebcams.length) {
effectiveType = 'danger';
}
} else if (warningZone) {
if (!warningZoneReceivers) {
effectiveType = 'warning';
}
if (warningZoneReceivers === usersWatchingWebcams.length) {
effectiveType = 'warning';
}
}
const lastEffectiveConnectionType = Users.findOne({ userId: Auth.userID });
if (lastEffectiveConnectionType
&& lastEffectiveConnectionType.effectiveConnectionType !== effectiveType) {
logger.info({ logCode: 'user_connection_instability' }, `User ${Auth.userID} effective connection is now ${effectiveType}`);
makeCall('setUserEffectiveConnectionType', effectiveType);
}
}, 5000);
};
export const stopBandwidthMonitoring = () => {
clearInterval(monitoringIntervalRef);
};
export const updateNavigatorConnection = ({ effectiveType, downlink, rtt }) => {
const doc = {
timestamp: new Date().getTime(),
event: NAVIGATOR_CONNECTION,
payload: {
effectiveType,
downlink,
rtt,
},
};
NetworkInformationLocal.insert(doc);
};
export const updateWebcamStats = (id, stats) => {
if (!stats) return;
const lastStatus = NetworkInformationLocal
.findOne(
{ event: WEBCAMS_GET_STATUS, 'payload.id': id },
{ sort: { timestamp: -1 } },
);
const { video } = stats;
const doc = {
timestamp: new Date().getTime(),
event: WEBCAMS_GET_STATUS,
payload: { id, stats: video },
};
if (lastStatus) {
const {
payload: {
stats: {
packetsLost,
packetsReceived,
packetsSent,
pliCount,
},
},
} = lastStatus;
const normalizedVideo = { ...video };
normalizedVideo.deltaPacketsLost = video.packetsLost - packetsLost;
normalizedVideo.deltaPacketsReceived = video.packetsReceived - packetsReceived;
normalizedVideo.deltaPacketsSent = video.packetsSent - packetsSent;
normalizedVideo.deltaPliCount = video.pliCount - pliCount;
doc.payload = {
id,
stats: normalizedVideo,
};
if (normalizedVideo.deltaPacketsLost > 0) {
makeCall('userInstabilityDetected', id);
}
}
NetworkInformationLocal.insert(doc);
};
export default {
NetworkInformationLocal,
updateCurrentWebcamsConnection,
deleteWebcamConnection,
getCurrentWebcams,
newWebcamConnection,
startBandwidthMonitoring,
stopBandwidthMonitoring,
updateNavigatorConnection,
updateWebcamStats,
};

View File

@ -1,5 +1,6 @@
import Storage from '/imports/ui/services/storage/session'; import Storage from '/imports/ui/services/storage/session';
import _ from 'lodash'; import _ from 'lodash';
import { makeCall } from '/imports/ui/services/api';
const SETTINGS = [ const SETTINGS = [
'application', 'application',
@ -33,9 +34,9 @@ class Settings {
}); });
// Sets default locale to browser locale // Sets default locale to browser locale
defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false || defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false
navigator.language || || navigator.language
defaultValues.application.locale; || defaultValues.application.locale;
this.setDefault(defaultValues); this.setDefault(defaultValues);
} }
@ -55,7 +56,15 @@ class Settings {
} }
save() { save() {
Object.keys(this).forEach(k => Storage.setItem(`settings${k}`, this[k].value)); Object.keys(this).forEach((k) => {
if (k === '_dataSaving') {
const { value: { viewParticipantsWebcams } } = this[k];
makeCall('userChangedSettings', 'viewParticipantsWebcams', viewParticipantsWebcams);
}
Storage.setItem(`settings${k}`, this[k].value);
});
} }
} }

View File

@ -31,6 +31,7 @@ const mapUser = (user) => {
isOnline: user.connectionStatus === 'online', isOnline: user.connectionStatus === 'online',
clientType: user.clientType, clientType: user.clientType,
loginTime: user.loginTime, loginTime: user.loginTime,
effectiveConnectionType: user.effectiveConnectionType,
externalUserId: user.extId, externalUserId: user.extId,
}; };

View File

@ -19,6 +19,7 @@ public:
basename: "/html5client" basename: "/html5client"
askForFeedbackOnLogout: false askForFeedbackOnLogout: false
allowUserLookup: false allowUserLookup: false
enableNetworkInformation: false
defaultSettings: defaultSettings:
application: application:
animations: true animations: true
@ -83,6 +84,10 @@ public:
connectionTimeout: 60000 connectionTimeout: 60000
showHelpButton: true showHelpButton: true
enableExternalVideo: true enableExternalVideo: true
effectiveConnection:
- critical
- danger
- warning
kurento: kurento:
wsUrl: HOST wsUrl: HOST
chromeDefaultExtensionKey: akgoaoikmbmhcopjgakkcepdgdgkjfbc chromeDefaultExtensionKey: akgoaoikmbmhcopjgakkcepdgdgkjfbc

View File

@ -610,6 +610,8 @@
"app.externalVideo.urlInput": "Add YouTube URL", "app.externalVideo.urlInput": "Add YouTube URL",
"app.externalVideo.urlError": "This URL isn't a valid YouTube video", "app.externalVideo.urlError": "This URL isn't a valid YouTube video",
"app.externalVideo.close": "Close", "app.externalVideo.close": "Close",
"app.network.connection.effective.slow": "We're noticing connectivity issues.",
"app.network.connection.effective.slow.help": "How to fix it?",
"app.externalVideo.noteLabel": "Note: Shared YouTube videos will not appear in the recording", "app.externalVideo.noteLabel": "Note: Shared YouTube videos will not appear in the recording",
"app.actionsBar.actionsDropdown.shareExternalVideo": "Share YouTube video", "app.actionsBar.actionsDropdown.shareExternalVideo": "Share YouTube video",
"app.actionsBar.actionsDropdown.stopShareExternalVideo": "Stop sharing YouTube video", "app.actionsBar.actionsDropdown.stopShareExternalVideo": "Stop sharing YouTube video",

View File

@ -19,6 +19,7 @@ import '/imports/api/users-settings/server';
import '/imports/api/voice-users/server'; import '/imports/api/voice-users/server';
import '/imports/api/whiteboard-multi-user/server'; import '/imports/api/whiteboard-multi-user/server';
import '/imports/api/video/server'; import '/imports/api/video/server';
import '/imports/api/network-information/server';
import '/imports/api/users-infos/server'; import '/imports/api/users-infos/server';
import '/imports/api/note/server'; import '/imports/api/note/server';
import '/imports/api/external-videos/server'; import '/imports/api/external-videos/server';