Merge pull request #7101 from jfsiebel/network-indicator
Implement audio quality bars
This commit is contained in:
commit
236c62700e
@ -13,6 +13,7 @@ import clearPresentationPods from '/imports/api/presentation-pods/server/modifie
|
||||
import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers';
|
||||
import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo';
|
||||
import clearNote from '/imports/api/note/server/modifiers/clearNote';
|
||||
import clearNetworkInformation from '/imports/api/network-information/server/modifiers/clearNetworkInformation';
|
||||
|
||||
export default function meetingHasEnded(meetingId) {
|
||||
return Meetings.remove({ meetingId }, () => {
|
||||
@ -28,6 +29,7 @@ export default function meetingHasEnded(meetingId) {
|
||||
clearVoiceUsers(meetingId);
|
||||
clearUserInfo(meetingId);
|
||||
clearNote(meetingId);
|
||||
clearNetworkInformation(meetingId);
|
||||
|
||||
return Logger.info(`Cleared Meetings with id ${meetingId}`);
|
||||
});
|
||||
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
import './methods';
|
||||
import './publisher';
|
@ -0,0 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import userInstabilityDetected from './methods/userInstabilityDetected';
|
||||
|
||||
Meteor.methods({
|
||||
userInstabilityDetected,
|
||||
});
|
@ -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);
|
||||
}
|
@ -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)');
|
||||
});
|
||||
}
|
@ -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);
|
@ -5,7 +5,9 @@ import assignPresenter from './methods/assignPresenter';
|
||||
import changeRole from './methods/changeRole';
|
||||
import removeUser from './methods/removeUser';
|
||||
import toggleUserLock from './methods/toggleUserLock';
|
||||
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
|
||||
import userActivitySign from './methods/userActivitySign';
|
||||
import userChangedSettings from './methods/userChangedSettings';
|
||||
import userLeftMeeting from './methods/userLeftMeeting';
|
||||
|
||||
Meteor.methods({
|
||||
@ -15,6 +17,8 @@ Meteor.methods({
|
||||
removeUser,
|
||||
validateAuthToken,
|
||||
toggleUserLock,
|
||||
setUserEffectiveConnectionType,
|
||||
userActivitySign,
|
||||
userChangedSettings,
|
||||
userLeftMeeting,
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -80,6 +80,7 @@ export default function addUser(meetingId, user) {
|
||||
isBreakoutUser: Meeting.meetingProp.isBreakout,
|
||||
parentId: Meeting.breakoutProps.parentId,
|
||||
},
|
||||
effectiveConnectionType: null,
|
||||
inactivityCheck: false,
|
||||
responseDelay: 0,
|
||||
loggedOut: false,
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -16,6 +16,7 @@ import AudioContainer from '../audio/container';
|
||||
import ChatAlertContainer from '../chat/alert/container';
|
||||
import BannerBarContainer from '/imports/ui/components/banner-bar/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 { styles } from './styles';
|
||||
@ -24,6 +25,7 @@ const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
|
||||
const APP_CONFIG = Meteor.settings.public.app;
|
||||
const DESKTOP_FONT_SIZE = APP_CONFIG.desktopFontSize;
|
||||
const MOBILE_FONT_SIZE = APP_CONFIG.mobileFontSize;
|
||||
const ENABLE_NETWORK_INFORMATION = APP_CONFIG.enableNetworkInformation;
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
userListLabel: {
|
||||
@ -110,11 +112,22 @@ class App extends Component {
|
||||
this.handleWindowResize();
|
||||
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');
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize, false);
|
||||
navigator.connection.addEventListener('change', this.handleNetworkConnection, false);
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
@ -125,6 +138,10 @@ class App extends Component {
|
||||
this.setState({ enableResize: shouldEnableResize });
|
||||
}
|
||||
|
||||
handleNetworkConnection() {
|
||||
updateNavigatorConnection(navigator.connection);
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
const { enableResize } = this.state;
|
||||
const { openPanel } = this.props;
|
||||
|
@ -5,8 +5,10 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||
import _ from 'lodash';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
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 SlowConnection from '/imports/ui/components/slow-connection/component';
|
||||
import NavBarService from '../nav-bar/service';
|
||||
|
||||
import NotificationsBar from './component';
|
||||
|
||||
@ -19,6 +21,14 @@ const STATUS_FAILED = 'failed';
|
||||
// failed to connect and waiting to try to reconnect
|
||||
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({
|
||||
failedMessage: {
|
||||
id: 'app.failedMessage',
|
||||
@ -60,6 +70,14 @@ const intlMessages = defineMessages({
|
||||
id: 'app.meeting.alertBreakoutEndsUnderOneMinute',
|
||||
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) => {
|
||||
@ -104,6 +122,22 @@ export default injectIntl(withTracker(({ intl }) => {
|
||||
const { status, connected, retryTime } = Meteor.status();
|
||||
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) {
|
||||
data.color = 'primary';
|
||||
switch (status) {
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
@ -16,6 +16,7 @@ const SUBSCRIPTIONS = [
|
||||
'users', 'meetings', 'polls', 'presentations', 'slides', 'captions',
|
||||
'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat',
|
||||
'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'note',
|
||||
'network-information',
|
||||
];
|
||||
|
||||
class Subscriptions extends Component {
|
||||
|
@ -490,6 +490,7 @@ class UserDropdown extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
compact,
|
||||
currentUser,
|
||||
user,
|
||||
intl,
|
||||
isMeetingLocked,
|
||||
@ -508,6 +509,8 @@ class UserDropdown extends PureComponent {
|
||||
|
||||
const userItemContentsStyle = {};
|
||||
|
||||
const { isModerator } = currentUser;
|
||||
|
||||
userItemContentsStyle[styles.dropdown] = true;
|
||||
userItemContentsStyle[styles.userListItem] = !isActionsOpen;
|
||||
userItemContentsStyle[styles.usertListItemWithMenu] = isActionsOpen;
|
||||
@ -551,7 +554,7 @@ class UserDropdown extends PureComponent {
|
||||
{<UserIcons
|
||||
{...{
|
||||
user,
|
||||
compact,
|
||||
isModerator,
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
|
@ -1,9 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
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';
|
||||
|
||||
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 = {
|
||||
isModerator: PropTypes.bool.isRequired,
|
||||
user: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
isPresenter: PropTypes.bool.isRequired,
|
||||
@ -11,31 +18,33 @@ const propTypes = {
|
||||
isModerator: PropTypes.bool.isRequired,
|
||||
image: PropTypes.string,
|
||||
}).isRequired,
|
||||
compact: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const UserIcons = (props) => {
|
||||
const {
|
||||
user,
|
||||
compact,
|
||||
isModerator,
|
||||
user: {
|
||||
effectiveConnectionType,
|
||||
id,
|
||||
},
|
||||
} = props;
|
||||
|
||||
if (compact || user.isSharingWebcam) {
|
||||
return null;
|
||||
}
|
||||
const showNetworkInformation = ENABLE_NETWORK_INFORMATION
|
||||
&& SLOW_CONNECTIONS_TYPES.includes(effectiveConnectionType)
|
||||
&& (id === Auth.userID || isModerator);
|
||||
|
||||
return (
|
||||
<div className={styles.userIcons}>
|
||||
{
|
||||
user.isSharingWebcam ?
|
||||
showNetworkInformation ? (
|
||||
<span className={styles.userIconsContainer}>
|
||||
<Icon iconName="video" />
|
||||
<SlowConnection effectiveConnectionType={effectiveConnectionType} iconOnly />
|
||||
</span>
|
||||
: null
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UserIcons.propTypes = propTypes;
|
||||
export default UserIcons;
|
||||
export default memo(UserIcons);
|
||||
|
@ -22,7 +22,7 @@
|
||||
justify-content: space-between;
|
||||
margin: 0 0 0 var(--sm-padding-x)/2;
|
||||
text-align: right;
|
||||
font-size: 1rem;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 1;
|
||||
color: var(--user-icons-color);
|
||||
|
||||
|
@ -7,13 +7,21 @@ import { fetchWebRTCMappedStunTurnServers } from '/imports/utils/fetchStunTurnSe
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
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 Auth from '/imports/ui/services/auth';
|
||||
|
||||
import VideoService from './service';
|
||||
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 intlClientErrors = defineMessages({
|
||||
@ -158,6 +166,30 @@ class VideoProvider extends Component {
|
||||
|
||||
this.visibility.onVisible(this.unpauseViewers);
|
||||
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 }) {
|
||||
@ -196,6 +228,8 @@ class VideoProvider extends Component {
|
||||
this.stopWebRTCPeer(id);
|
||||
});
|
||||
|
||||
clearInterval(this.currentWebcamsStatsInterval);
|
||||
|
||||
// Close websocket connection to prevent multiple reconnects from happening
|
||||
this.ws.close();
|
||||
}
|
||||
@ -441,6 +475,10 @@ class VideoProvider extends Component {
|
||||
webRtcPeer.dispose();
|
||||
}
|
||||
delete this.webRtcPeers[id];
|
||||
if (ENABLE_NETWORK_INFORMATION) {
|
||||
deleteWebcamConnection(id);
|
||||
updateCurrentWebcamsConnection(this.webRtcPeers);
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
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 statsState = stats;
|
||||
let promise;
|
||||
@ -735,6 +777,7 @@ class VideoProvider extends Component {
|
||||
encodeUsagePercent: videoInOrOutbound.encodeUsagePercent,
|
||||
rtt: videoInOrOutbound.rtt,
|
||||
currentDelay: videoInOrOutbound.currentDelay,
|
||||
pliCount: videoInOrOutbound.pliCount,
|
||||
};
|
||||
|
||||
const videoStatsArray = statsState;
|
||||
@ -742,7 +785,10 @@ class VideoProvider extends Component {
|
||||
while (videoStatsArray.length > 5) { // maximum interval to consider
|
||||
videoStatsArray.shift();
|
||||
}
|
||||
this.setState({ stats: videoStatsArray });
|
||||
|
||||
if (!monitoring) {
|
||||
this.setState({ stats: videoStatsArray });
|
||||
}
|
||||
|
||||
const firstVideoStats = videoStatsArray[0];
|
||||
const lastVideoStats = videoStatsArray[videoStatsArray.length - 1];
|
||||
@ -767,9 +813,9 @@ class VideoProvider extends Component {
|
||||
|
||||
let videoBitrate;
|
||||
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);
|
||||
videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived) * 100)) || 0).toFixed(1);
|
||||
videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived)) * 100) || 0).toFixed(1);
|
||||
} else {
|
||||
videoLostPercentage = (((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent)) * 100) || 0).toFixed(1);
|
||||
videoBitrate = Math.floor(videoKbitsSentPerSecond || 0);
|
||||
@ -793,6 +839,7 @@ class VideoProvider extends Component {
|
||||
encodeUsagePercent: videoStats.encodeUsagePercent,
|
||||
rtt: videoStats.rtt,
|
||||
currentDelay: videoStats.currentDelay,
|
||||
pliCount: videoStats.pliCount,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import Storage from '/imports/ui/services/storage/session';
|
||||
import _ from 'lodash';
|
||||
import { makeCall } from '/imports/ui/services/api';
|
||||
|
||||
const SETTINGS = [
|
||||
'application',
|
||||
@ -33,9 +34,9 @@ class Settings {
|
||||
});
|
||||
|
||||
// Sets default locale to browser locale
|
||||
defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false ||
|
||||
navigator.language ||
|
||||
defaultValues.application.locale;
|
||||
defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false
|
||||
|| navigator.language
|
||||
|| defaultValues.application.locale;
|
||||
|
||||
this.setDefault(defaultValues);
|
||||
}
|
||||
@ -55,7 +56,15 @@ class Settings {
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,7 @@ const mapUser = (user) => {
|
||||
isOnline: user.connectionStatus === 'online',
|
||||
clientType: user.clientType,
|
||||
loginTime: user.loginTime,
|
||||
effectiveConnectionType: user.effectiveConnectionType,
|
||||
externalUserId: user.extId,
|
||||
};
|
||||
|
||||
|
@ -19,6 +19,7 @@ public:
|
||||
basename: "/html5client"
|
||||
askForFeedbackOnLogout: false
|
||||
allowUserLookup: false
|
||||
enableNetworkInformation: false
|
||||
defaultSettings:
|
||||
application:
|
||||
animations: true
|
||||
@ -83,6 +84,10 @@ public:
|
||||
connectionTimeout: 60000
|
||||
showHelpButton: true
|
||||
enableExternalVideo: true
|
||||
effectiveConnection:
|
||||
- critical
|
||||
- danger
|
||||
- warning
|
||||
kurento:
|
||||
wsUrl: HOST
|
||||
chromeDefaultExtensionKey: akgoaoikmbmhcopjgakkcepdgdgkjfbc
|
||||
|
@ -610,6 +610,8 @@
|
||||
"app.externalVideo.urlInput": "Add YouTube URL",
|
||||
"app.externalVideo.urlError": "This URL isn't a valid YouTube video",
|
||||
"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.actionsBar.actionsDropdown.shareExternalVideo": "Share YouTube video",
|
||||
"app.actionsBar.actionsDropdown.stopShareExternalVideo": "Stop sharing YouTube video",
|
||||
|
@ -19,6 +19,7 @@ import '/imports/api/users-settings/server';
|
||||
import '/imports/api/voice-users/server';
|
||||
import '/imports/api/whiteboard-multi-user/server';
|
||||
import '/imports/api/video/server';
|
||||
import '/imports/api/network-information/server';
|
||||
import '/imports/api/users-infos/server';
|
||||
import '/imports/api/note/server';
|
||||
import '/imports/api/external-videos/server';
|
||||
|
Loading…
Reference in New Issue
Block a user