init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { IStore } from '../app/types';
import getRoomName from '../base/config/getRoomName';
import { downloadJSON } from '../base/util/downloadJSON';
/**
* Create an action for saving the conference logs.
*
* @returns {Function}
*/
export function saveLogs() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const logs = getState()['features/base/connection'].connection?.getLogs();
const roomName = getRoomName() || '';
downloadJSON(logs ?? {}, `meetlog-${roomName}.json`);
};
}

View File

@@ -0,0 +1,225 @@
import { Component } from 'react';
import { IReduxState } from '../../app/types';
import { getVirtualScreenshareParticipantOwnerId } from '../../base/participants/functions';
import statsEmitter from '../statsEmitter';
const defaultAutoHideTimeout = 5000;
/**
* The connection quality percentage that must be reached to be considered of
* good quality and can result in the connection indicator being hidden.
*
* @type {number}
*/
export const INDICATOR_DISPLAY_THRESHOLD = 30;
/**
* The type of the React {@code Component} props of {@link ConnectionIndicator}.
*/
export interface IProps {
/**
* How long the connection indicator should remain displayed before hiding.
*/
_autoHideTimeout: number;
/**
* Whether or not the statistics are for screen share.
*/
_isVirtualScreenshareParticipant: boolean;
/**
* Custom icon style.
*/
iconStyle?: Object;
/**
* The ID of the participant associated with the displayed connection indication and
* stats.
*/
participantId: string;
}
/**
* The type of the React {@code Component} state of {@link ConnectionIndicator}.
*/
export interface IState {
/**
* Whether or not a CSS class should be applied to the root for hiding the
* connection indicator. By default the indicator should start out hidden
* because the current connection status is not known at mount.
*/
showIndicator: boolean;
/**
* Cache of the stats received from subscribing to stats emitting. The keys
* should be the name of the stat. With each stat update, updates stats are
* mixed in with cached stats and a new stats object is set in state.
*/
stats: {
bandwidth?: any;
bitrate?: any;
bridgeCount?: any;
codec?: any;
framerate?: any;
maxEnabledResolution?: any;
packetLoss?: any;
percent?: number;
resolution?: any;
serverRegion?: any;
transport?: any;
};
}
/**
* Implements a React {@link Component} which displays the current connection
* quality.
*
* @augments {Component}
*/
class AbstractConnectionIndicator<P extends IProps, S extends IState> extends Component<P, S> {
/**
* The timeout for automatically hiding the indicator.
*/
autoHideTimeout: number | undefined;
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
* @param {P} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onStatsUpdated = this._onStatsUpdated.bind(this);
}
/**
* Starts listening for stat updates.
*
* @inheritdoc
* returns {void}
*/
override componentDidMount() {
statsEmitter.subscribeToClientStats(this._getRealParticipantId(this.props), this._onStatsUpdated);
}
/**
* Updates which user's stats are being listened to.
*
* @inheritdoc
* returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
const prevParticipantId = this._getRealParticipantId(prevProps);
const participantId = this._getRealParticipantId(this.props);
if (prevParticipantId !== participantId) {
statsEmitter.unsubscribeToClientStats(prevParticipantId, this._onStatsUpdated);
statsEmitter.subscribeToClientStats(participantId, this._onStatsUpdated);
}
}
/**
* Cleans up any queued processes, which includes listening for new stats
* and clearing any timeout to hide the indicator.
*
* @private
* @returns {void}
*/
override componentWillUnmount() {
statsEmitter.unsubscribeToClientStats(this._getRealParticipantId(this.props), this._onStatsUpdated);
clearTimeout(this.autoHideTimeout ?? 0);
}
/**
* Gets the "real" participant ID. FOr a virtual screenshare participant, that is its "owner".
*
* @param {Props} props - The props where to extract the data from.
* @returns {string | undefined } The resolved participant ID.
*/
_getRealParticipantId(props: IProps) {
if (props._isVirtualScreenshareParticipant) {
return getVirtualScreenshareParticipantOwnerId(props.participantId);
}
return props.participantId;
}
/**
* Callback invoked when new connection stats associated with the passed in
* user ID are available. Will update the component's display of current
* statistics.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {void}
*/
_onStatsUpdated(stats = { connectionQuality: undefined }) {
// Rely on React to batch setState actions.
const { connectionQuality } = stats;
const newPercentageState = typeof connectionQuality === 'undefined'
? {} : { percent: connectionQuality };
const newStats = Object.assign(
{},
this.state.stats,
stats,
newPercentageState);
this.setState({
stats: newStats
});
this._updateIndicatorAutoHide(newStats.percent ?? 0);
}
/**
* Updates the internal state for automatically hiding the indicator.
*
* @param {number} percent - The current connection quality percentage
* between the values 0 and 100.
* @private
* @returns {void}
*/
_updateIndicatorAutoHide(percent: number) {
if (percent < INDICATOR_DISPLAY_THRESHOLD) {
clearTimeout(this.autoHideTimeout ?? 0);
this.autoHideTimeout = undefined;
this.setState({
showIndicator: true
});
} else if (this.autoHideTimeout) {
// This clause is intentionally left blank because no further action
// is needed if the percent is below the threshold and there is an
// autoHideTimeout set.
} else {
this.autoHideTimeout = window.setTimeout(() => {
this.setState({
showIndicator: false
});
}, this.props._autoHideTimeout);
}
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code ConnectorIndicator} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
export function mapStateToProps(state: IReduxState) {
return {
_autoHideTimeout: state['features/base/config'].connectionIndicators?.autoHideTimeout ?? defaultAutoHideTimeout
};
}
export default AbstractConnectionIndicator;

View File

@@ -0,0 +1,206 @@
import React from 'react';
import { StyleProp, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { IconConnection } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getLocalParticipant,
getParticipantById,
isScreenShareParticipant
} from '../../../base/participants/functions';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
import {
getTrackByMediaTypeAndParticipant
} from '../../../base/tracks/functions.native';
import indicatorStyles from '../../../filmstrip/components/native/styles';
import {
isTrackStreamingStatusInactive,
isTrackStreamingStatusInterrupted
} from '../../functions';
import AbstractConnectionIndicator, {
IProps as AbstractProps,
mapStateToProps as _abstractMapStateToProps
} from '../AbstractConnectionIndicator';
import {
CONNECTOR_INDICATOR_COLORS,
CONNECTOR_INDICATOR_LOST,
CONNECTOR_INDICATOR_OTHER,
iconStyle
} from './styles';
type IProps = AbstractProps & {
/**
* Whether connection indicators are disabled or not.
*/
_connectionIndicatorDisabled: boolean;
/**
* Whether the inactive connection indicator is disabled or not.
*/
_connectionIndicatorInactiveDisabled: boolean;
/**
* Whether the connection is inactive or not.
*/
_isConnectionStatusInactive?: boolean;
/**
* Whether the connection is interrupted or not.
*/
_isConnectionStatusInterrupted?: boolean;
/**
* Whether the current participant is a virtual screenshare.
*/
_isVirtualScreenshareParticipant: boolean;
/**
* Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Icon style override.
*/
iconStyle?: any;
};
type IState = {
autoHideTimeout: number | undefined;
showIndicator: boolean;
stats: any;
};
/**
* Implements an indicator to show the quality of the connection of a participant.
*/
class ConnectionIndicator extends AbstractConnectionIndicator<IProps, IState> {
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
autoHideTimeout: undefined,
showIndicator: false,
stats: {}
};
}
/**
* Get the icon configuration from CONNECTOR_INDICATOR_COLORS which has a percentage
* that matches or exceeds the passed in percentage. The implementation
* assumes CONNECTOR_INDICATOR_COLORS is already sorted by highest to lowest
* percentage.
*
* @param {number} percent - The connection percentage, out of 100, to find
* the closest matching configuration for.
* @private
* @returns {Object}
*/
_getDisplayConfiguration(percent: number): any {
return CONNECTOR_INDICATOR_COLORS.find(x => percent >= x.percent) || {};
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_connectionIndicatorInactiveDisabled,
_connectionIndicatorDisabled,
_isVirtualScreenshareParticipant,
_isConnectionStatusInactive,
_isConnectionStatusInterrupted
} = this.props;
const {
showIndicator,
stats
} = this.state;
const { percent } = stats;
if (!showIndicator || typeof percent === 'undefined'
|| _connectionIndicatorDisabled || _isVirtualScreenshareParticipant) {
return null;
}
let indicatorColor;
if (_isConnectionStatusInactive) {
if (_connectionIndicatorInactiveDisabled) {
return null;
}
indicatorColor = CONNECTOR_INDICATOR_OTHER;
} else if (_isConnectionStatusInterrupted) {
indicatorColor = CONNECTOR_INDICATOR_LOST;
} else {
const displayConfig = this._getDisplayConfiguration(percent);
if (!displayConfig) {
return null;
}
indicatorColor = displayConfig.color;
}
return (
<View
style = { [
indicatorStyles.indicatorContainer as StyleProp<ViewStyle>,
{ backgroundColor: indicatorColor }
] }>
<BaseIndicator
icon = { IconConnection }
iconStyle = { this.props.iconStyle || iconStyle } />
</View>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantId } = ownProps;
const tracks = state['features/base/tracks'];
const participant = participantId ? getParticipantById(state, participantId) : getLocalParticipant(state);
const _isVirtualScreenshareParticipant = isScreenShareParticipant(participant);
let _isConnectionStatusInactive;
let _isConnectionStatusInterrupted;
if (!_isVirtualScreenshareParticipant) {
const _videoTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantId);
_isConnectionStatusInactive = isTrackStreamingStatusInactive(_videoTrack);
_isConnectionStatusInterrupted = isTrackStreamingStatusInterrupted(_videoTrack);
}
return {
..._abstractMapStateToProps(state),
_connectionIndicatorInactiveDisabled:
Boolean(state['features/base/config'].connectionIndicators?.inactiveDisabled),
_connectionIndicatorDisabled:
Boolean(state['features/base/config'].connectionIndicators?.disabled),
_isVirtualScreenshareParticipant,
_isConnectionStatusInactive,
_isConnectionStatusInterrupted
};
}
export default connect(_mapStateToProps)(ConnectionIndicator);

View File

@@ -0,0 +1,29 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import { INDICATOR_DISPLAY_THRESHOLD } from '../AbstractConnectionIndicator';
export const CONNECTOR_INDICATOR_LOST = BaseTheme.palette.ui05;
export const CONNECTOR_INDICATOR_OTHER = BaseTheme.palette.action01;
export const CONNECTOR_INDICATOR_COLORS = [
// Full (3 bars)
{
color: BaseTheme.palette.success01,
percent: INDICATOR_DISPLAY_THRESHOLD
},
// 2 bars.
{
color: BaseTheme.palette.warning01,
percent: 10
},
// 1 bar.
{
color: BaseTheme.palette.iconError,
percent: 0
}
];
export const iconStyle = {
fontSize: 14
};

View File

@@ -0,0 +1,142 @@
import React, { KeyboardEvent, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
// @ts-ignore
import { MIN_ASSUMED_BANDWIDTH_BPS } from '../../../../../modules/API/constants';
import { IReduxState } from '../../../app/types';
import { setAssumedBandwidthBps as saveAssumedBandwidthBps } from '../../../base/conference/actions';
import { IconInfoCircle } from '../../../base/icons/svg';
import Dialog from '../../../base/ui/components/web/Dialog';
import Input from '../../../base/ui/components/web/Input';
const useStyles = makeStyles()(theme => {
return {
content: {
color: theme.palette.text01
},
info: {
background: theme.palette.ui01,
...theme.typography.labelRegular,
color: theme.palette.text02,
marginTop: theme.spacing(2)
},
possibleValues: {
margin: 0,
paddingLeft: theme.spacing(4)
}
};
});
/**
* Bandwidth settings dialog component.
*
* @returns {ReactElement}
*/
const BandwidthSettingsDialog = () => {
const { classes } = useStyles();
const { t } = useTranslation();
const dispatch = useDispatch();
const [ showAssumedBandwidthInfo, setShowAssumedBandwidthInfo ] = useState(false);
const currentAssumedBandwidthBps = useSelector(
(state: IReduxState) => state['features/base/conference'].assumedBandwidthBps
);
const [ assumedBandwidthBps, setAssumedBandwidthBps ] = useState(
currentAssumedBandwidthBps === MIN_ASSUMED_BANDWIDTH_BPS
|| currentAssumedBandwidthBps === undefined
? ''
: currentAssumedBandwidthBps
);
/**
* Changes the assumed bandwidth bps.
*
* @param {string} value - The key event to handle.
*
* @returns {void}
*/
const onAssumedBandwidthBpsChange = useCallback((value: string) => {
setAssumedBandwidthBps(value);
}, [ setAssumedBandwidthBps ]);
/**
* Persists the assumed bandwidth bps.
*
* @param {string} value - The key event to handle.
*
* @returns {void}
*/
const onAssumedBandwidthBpsSave = useCallback(() => {
if (assumedBandwidthBps !== currentAssumedBandwidthBps) {
dispatch(saveAssumedBandwidthBps(Number(
assumedBandwidthBps === '' ? MIN_ASSUMED_BANDWIDTH_BPS : assumedBandwidthBps
)));
}
}, [ assumedBandwidthBps, currentAssumedBandwidthBps, dispatch, saveAssumedBandwidthBps ]);
/**
* Validates the assumed bandwidth bps.
*
* @param {KeyboardEvent<any>} e - The key event to handle.
*
* @returns {void}
*/
const onAssumedBandwidthBpsKeyPress = useCallback((e: KeyboardEvent<any>) => {
const isValid = (e.charCode !== 8 && e.charCode === 0) || (e.charCode >= 48 && e.charCode <= 57);
if (!isValid) {
e.preventDefault();
}
}, []);
/**
* Callback invoked to hide or show the possible values
* of the assumed bandwidth setting.
*
* @returns {void}
*/
const toggleInfoPanel = useCallback(() => {
setShowAssumedBandwidthInfo(!showAssumedBandwidthInfo);
}, [ setShowAssumedBandwidthInfo, showAssumedBandwidthInfo ]);
return (
<Dialog
onSubmit = { onAssumedBandwidthBpsSave }
titleKey = 'bandwidthSettings.title'>
<div className = { classes.content }>
<Input
bottomLabel = { t('bandwidthSettings.assumedBandwidthBpsWarning') }
icon = { IconInfoCircle }
iconClick = { toggleInfoPanel }
id = 'setAssumedBandwidthBps'
label = { t('bandwidthSettings.setAssumedBandwidthBps') }
minValue = { 0 }
name = 'assumedBandwidthBps'
onChange = { onAssumedBandwidthBpsChange }
onKeyPress = { onAssumedBandwidthBpsKeyPress }
placeholder = { t('bandwidthSettings.assumedBandwidthBps') }
type = 'number'
value = { assumedBandwidthBps } />
{showAssumedBandwidthInfo && (
<div className = { classes.info }>
<span>{t('bandwidthSettings.possibleValues')}:</span>
<ul className = { classes.possibleValues }>
<li>
<b>{t('bandwidthSettings.leaveEmpty')}</b> {t('bandwidthSettings.leaveEmptyEffect')}
</li>
<li><b>0</b> {t('bandwidthSettings.zeroEffect')}</li>
<li>
<b>{t('bandwidthSettings.customValue')}</b> {t('bandwidthSettings.customValueEffect')}
</li>
</ul>
</div>
)}
</div>
</Dialog>
);
};
export default BandwidthSettingsDialog;

View File

@@ -0,0 +1,405 @@
import { Theme } from '@mui/material';
import clsx from 'clsx';
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getLocalParticipant,
getParticipantById,
isScreenShareParticipant
} from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import {
getTrackByMediaTypeAndParticipant,
getVirtualScreenshareParticipantTrack
} from '../../../base/tracks/functions';
import { ITrack } from '../../../base/tracks/types';
import { pixelsToRem } from '../../../base/ui/functions.any';
import {
isTrackStreamingStatusInactive,
isTrackStreamingStatusInterrupted
} from '../../functions';
import AbstractConnectionIndicator, {
IProps as AbstractProps,
IState as AbstractState,
INDICATOR_DISPLAY_THRESHOLD,
mapStateToProps as _abstractMapStateToProps
} from '../AbstractConnectionIndicator';
import ConnectionIndicatorContent from './ConnectionIndicatorContent';
import { ConnectionIndicatorIcon } from './ConnectionIndicatorIcon';
/**
* An array of display configurations for the connection indicator and its bars.
* The ordering is done specifically for faster iteration to find a matching
* configuration to the current connection strength percentage.
*
* @type {Object[]}
*/
const QUALITY_TO_WIDTH: Array<{
colorClass: string;
percent: number;
tip: string;
}> = [
// Full (3 bars)
{
colorClass: 'status-high',
percent: INDICATOR_DISPLAY_THRESHOLD,
tip: 'connectionindicator.quality.good'
},
// 2 bars
{
colorClass: 'status-med',
percent: 10,
tip: 'connectionindicator.quality.nonoptimal'
},
// 1 bar
{
colorClass: 'status-low',
percent: 0,
tip: 'connectionindicator.quality.poor'
}
// Note: we never show 0 bars as long as there is a connection.
];
/**
* The type of the React {@code Component} props of {@link ConnectionIndicator}.
*/
interface IProps extends AbstractProps, WithTranslation {
/**
* Disable/enable inactive indicator.
*/
_connectionIndicatorInactiveDisabled: boolean;
/**
* Whether the connection status is inactive.
*/
_isConnectionStatusInactive: boolean;
/**
* Whether the connection status is interrupted.
*/
_isConnectionStatusInterrupted?: boolean;
/**
* Whether the indicator popover is disabled.
*/
_popoverDisabled: boolean;
/**
* The participant's video track;.
*/
_videoTrack?: ITrack;
/**
* Whether or not the component should ignore setting a visibility class for
* hiding the component when the connection quality is not strong.
*/
alwaysVisible: boolean;
/**
* The audio SSRC of this client.
*/
audioSsrc?: number;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not clicking the indicator should display a popover for more
* details.
*/
enableStatsDisplay: boolean;
/**
* The font-size for the icon.
*/
iconSize: number;
/**
* Relative to the icon from where the popover for more connection details
* should display.
*/
statsPopoverPosition: string;
}
interface IState extends AbstractState {
/**
* Whether popover is visible or not.
*/
popoverVisible: boolean;
}
const styles = (theme: Theme) => {
return {
container: {
display: 'inline-block'
},
hidden: {
display: 'none'
},
icon: {
padding: '4px',
borderRadius: '4px',
'&.status-high': {
backgroundColor: theme.palette.success01
},
'&.status-med': {
backgroundColor: theme.palette.warning01
},
'&.status-low': {
backgroundColor: theme.palette.iconError
},
'&.status-disabled': {
background: 'transparent'
},
'&.status-lost': {
backgroundColor: theme.palette.ui05
},
'&.status-other': {
backgroundColor: theme.palette.action01
}
},
inactiveIcon: {
padding: 0,
borderRadius: '50%'
}
};
};
/**
* Implements a React {@link Component} which displays the current connection
* quality percentage and has a popover to show more detailed connection stats.
*
* @augments {Component}
*/
class ConnectionIndicator extends AbstractConnectionIndicator<IProps, IState> {
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
showIndicator: false,
stats: {},
popoverVisible: false
};
this._onShowPopover = this._onShowPopover.bind(this);
this._onHidePopover = this._onHidePopover.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { enableStatsDisplay, participantId, statsPopoverPosition, t } = this.props;
const classes = withStyles.getClasses(this.props);
const visibilityClass = this._getVisibilityClass();
if (this.props._popoverDisabled) {
return this._renderIndicator();
}
return (
<Popover
className = { clsx(classes.container, visibilityClass) }
content = { <ConnectionIndicatorContent
inheritedStats = { this.state.stats }
participantId = { participantId } /> }
disablePopover = { !enableStatsDisplay }
headingLabel = { t('videothumbnail.connectionInfo') }
id = 'participant-connection-indicator'
onPopoverClose = { this._onHidePopover }
onPopoverOpen = { this._onShowPopover }
position = { statsPopoverPosition }
visible = { this.state.popoverVisible }>
{ this._renderIndicator() }
</Popover>
);
}
/**
* Returns a CSS class that interprets the current connection status as a
* color.
*
* @private
* @returns {string}
*/
_getConnectionColorClass() {
// TODO We currently do not have logic to emit and handle stats changes for tracks.
const { percent } = this.state.stats;
const {
_isConnectionStatusInactive,
_isConnectionStatusInterrupted,
_connectionIndicatorInactiveDisabled
} = this.props;
if (_isConnectionStatusInactive) {
if (_connectionIndicatorInactiveDisabled) {
return 'status-disabled';
}
return 'status-other';
} else if (_isConnectionStatusInterrupted) {
return 'status-lost';
} else if (typeof percent === 'undefined') {
return 'status-high';
}
return this._getDisplayConfiguration(percent).colorClass;
}
/**
* Get the icon configuration from QUALITY_TO_WIDTH which has a percentage
* that matches or exceeds the passed in percentage. The implementation
* assumes QUALITY_TO_WIDTH is already sorted by highest to lowest
* percentage.
*
* @param {number} percent - The connection percentage, out of 100, to find
* the closest matching configuration for.
* @private
* @returns {Object}
*/
_getDisplayConfiguration(percent: number): any {
return QUALITY_TO_WIDTH.find(x => percent >= x.percent) || {};
}
/**
* Returns additional class names to add to the root of the component. The
* class names are intended to be used for hiding or showing the indicator.
*
* @private
* @returns {string}
*/
_getVisibilityClass() {
const { _isConnectionStatusInactive, _isConnectionStatusInterrupted } = this.props;
const classes = withStyles.getClasses(this.props);
return this.state.showIndicator
|| this.props.alwaysVisible
|| _isConnectionStatusInterrupted
|| _isConnectionStatusInactive
? '' : classes.hidden;
}
/**
* Hides popover.
*
* @private
* @returns {void}
*/
_onHidePopover() {
this.setState({ popoverVisible: false });
}
/**
* Shows popover.
*
* @private
* @returns {void}
*/
_onShowPopover() {
this.setState({ popoverVisible: true });
}
/**
* Creates a ReactElement for displaying the indicator (GSM bar).
*
* @returns {ReactElement}
*/
_renderIndicator() {
const {
_isConnectionStatusInactive,
_isConnectionStatusInterrupted,
_connectionIndicatorInactiveDisabled,
_videoTrack,
classes,
iconSize,
t
} = this.props;
return (
<div
style = {{ fontSize: pixelsToRem(iconSize) }}>
<span className = 'sr-only'>{ t('videothumbnail.connectionInfo') }</span>
<ConnectionIndicatorIcon
classes = { classes }
colorClass = { this._getConnectionColorClass() }
connectionIndicatorInactiveDisabled = { _connectionIndicatorInactiveDisabled }
isConnectionStatusInactive = { _isConnectionStatusInactive }
isConnectionStatusInterrupted = { _isConnectionStatusInterrupted }
track = { _videoTrack } />
</div>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantId } = ownProps;
const tracks = state['features/base/tracks'];
const participant = participantId ? getParticipantById(state, participantId) : getLocalParticipant(state);
let _videoTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantId);
if (isScreenShareParticipant(participant)) {
_videoTrack = getVirtualScreenshareParticipantTrack(tracks, participantId);
}
const _isConnectionStatusInactive = isTrackStreamingStatusInactive(_videoTrack);
const _isConnectionStatusInterrupted = isTrackStreamingStatusInterrupted(_videoTrack);
return {
..._abstractMapStateToProps(state),
_connectionIndicatorInactiveDisabled:
Boolean(state['features/base/config'].connectionIndicators?.inactiveDisabled),
_isVirtualScreenshareParticipant: isScreenShareParticipant(participant),
_popoverDisabled: Boolean(state['features/base/config'].connectionIndicators?.disableDetails),
_isConnectionStatusInactive,
_isConnectionStatusInterrupted,
_videoTrack
};
}
export default connect(_mapStateToProps)(translate(withStyles(ConnectionIndicator, styles)));

View File

@@ -0,0 +1,376 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getLocalParticipant,
getParticipantById,
isScreenShareParticipant
} from '../../../base/participants/functions';
import {
getTrackByMediaTypeAndParticipant,
getVirtualScreenshareParticipantTrack
} from '../../../base/tracks/functions.web';
import ConnectionStatsTable from '../../../connection-stats/components/ConnectionStatsTable';
import { saveLogs } from '../../actions.web';
import {
isTrackStreamingStatusInactive,
isTrackStreamingStatusInterrupted
} from '../../functions';
import AbstractConnectionIndicator, {
IProps as AbstractProps,
IState as AbstractState,
INDICATOR_DISPLAY_THRESHOLD
} from '../AbstractConnectionIndicator';
import BandwidthSettingsDialog from './BandwidthSettingsDialog';
/**
* An array of display configurations for the connection indicator and its bars.
* The ordering is done specifically for faster iteration to find a matching
* configuration to the current connection strength percentage.
*
* @type {Object[]}
*/
const QUALITY_TO_WIDTH = [
// Full (3 bars)
{
colorClass: 'status-high',
percent: INDICATOR_DISPLAY_THRESHOLD,
tip: 'connectionindicator.quality.good',
width: '100%'
},
// 2 bars
{
colorClass: 'status-med',
percent: 10,
tip: 'connectionindicator.quality.nonoptimal',
width: '66%'
},
// 1 bar
{
colorClass: 'status-low',
percent: 0,
tip: 'connectionindicator.quality.poor',
width: '33%'
}
// Note: we never show 0 bars as long as there is a connection.
];
/**
* The type of the React {@code Component} props of {@link ConnectionIndicator}.
*/
interface IProps extends AbstractProps, WithTranslation {
/**
* The audio SSRC of this client.
*/
_audioSsrc: number;
/**
* Whether or not should display the "Show More" link in the local video
* stats table.
*/
_disableShowMoreStats: boolean;
/**
* Whether to enable assumed bandwidth.
*/
_enableAssumedBandwidth?: boolean;
/**
* Whether or not should display the "Save Logs" link in the local video
* stats table.
*/
_enableSaveLogs: boolean;
_isConnectionStatusInactive: boolean;
_isConnectionStatusInterrupted: boolean;
_isE2EEVerified?: boolean;
/**
* Whether or not the displays stats are for local video.
*/
_isLocalVideo: boolean;
/**
* Whether is narrow layout or not.
*/
_isNarrowLayout: boolean;
/**
* Invoked to open the bandwidth settings dialog.
*/
_onOpenBandwidthDialog: () => void;
/**
* Invoked to save the conference logs.
*/
_onSaveLogs: () => void;
/**
* The region reported by the participant.
*/
_region?: string;
/**
* The video SSRC of this client.
*/
_videoSsrc: number;
/**
* Css class to apply on container.
*/
className: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Optional param for passing existing connection stats on component instantiation.
*/
inheritedStats: any;
}
/**
* The type of the React {@code Component} state of {@link ConnectionIndicator}.
*/
interface IState extends AbstractState {
autoHideTimeout?: number;
/**
* Whether or not the popover content should display additional statistics.
*/
showMoreStats: boolean;
}
/**
* Implements a React {@link Component} which displays the current connection
* quality percentage and has a popover to show more detailed connection stats.
*
* @augments {Component}
*/
class ConnectionIndicatorContent extends AbstractConnectionIndicator<IProps, IState> {
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
autoHideTimeout: undefined,
showIndicator: false,
showMoreStats: false,
stats: props.inheritedStats || {}
};
// Bind event handlers so they are only bound once for every instance.
this._onToggleShowMore = this._onToggleShowMore.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
bandwidth,
bitrate,
bridgeCount,
codec,
framerate,
maxEnabledResolution,
packetLoss,
resolution,
serverRegion,
transport
} = this.state.stats;
return (
<ConnectionStatsTable
audioSsrc = { this.props._audioSsrc }
bandwidth = { bandwidth }
bitrate = { bitrate }
bridgeCount = { bridgeCount }
codec = { codec }
connectionSummary = { this._getConnectionStatusTip() }
disableShowMoreStats = { this.props._disableShowMoreStats }
e2eeVerified = { this.props._isE2EEVerified }
enableAssumedBandwidth = { this.props._enableAssumedBandwidth }
enableSaveLogs = { this.props._enableSaveLogs }
framerate = { framerate }
isLocalVideo = { this.props._isLocalVideo }
isNarrowLayout = { this.props._isNarrowLayout }
isVirtualScreenshareParticipant = { this.props._isVirtualScreenshareParticipant }
maxEnabledResolution = { maxEnabledResolution }
onOpenBandwidthDialog = { this.props._onOpenBandwidthDialog }
onSaveLogs = { this.props._onSaveLogs }
onShowMore = { this._onToggleShowMore }
packetLoss = { packetLoss }
participantId = { this.props.participantId }
region = { this.props._region ?? '' }
resolution = { resolution }
serverRegion = { serverRegion }
shouldShowMore = { this.state.showMoreStats }
transport = { transport }
videoSsrc = { this.props._videoSsrc } />
);
}
/**
* Returns a string that describes the current connection status.
*
* @private
* @returns {string}
*/
_getConnectionStatusTip() {
let tipKey;
const { _isConnectionStatusInactive, _isConnectionStatusInterrupted } = this.props;
switch (true) {
case _isConnectionStatusInterrupted:
tipKey = 'connectionindicator.quality.lost';
break;
case _isConnectionStatusInactive:
tipKey = 'connectionindicator.quality.inactive';
break;
default: {
const { percent } = this.state.stats;
if (typeof percent === 'undefined') {
// If percentage is undefined then there are no stats available
// yet, likely because only a local connection has been
// established so far. Assume a strong connection to start.
tipKey = 'connectionindicator.quality.good';
} else {
const config = this._getDisplayConfiguration(percent);
tipKey = config.tip;
}
}
}
return this.props.t(tipKey);
}
/**
* Get the icon configuration from QUALITY_TO_WIDTH which has a percentage
* that matches or exceeds the passed in percentage. The implementation
* assumes QUALITY_TO_WIDTH is already sorted by highest to lowest
* percentage.
*
* @param {number} percent - The connection percentage, out of 100, to find
* the closest matching configuration for.
* @private
* @returns {Object}
*/
_getDisplayConfiguration(percent: number) {
return QUALITY_TO_WIDTH.find(x => percent >= x.percent) || { tip: '' };
}
/**
* Callback to invoke when the show more link in the popover content is
* clicked. Sets the state which will determine if the popover should show
* additional statistics about the connection.
*
* @returns {void}
*/
_onToggleShowMore() {
this.setState({ showMoreStats: !this.state.showMoreStats });
}
}
/**
* Maps redux actions to the props of the component.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* _onSaveLogs: Function,
* }}
* @private
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
/**
* Saves the conference logs.
*
* @returns {Function}
*/
_onSaveLogs() {
dispatch(saveLogs());
},
/**
* Opens the bandwidth settings dialog.
*
* @returns {void}
*/
_onOpenBandwidthDialog() {
dispatch(openDialog(BandwidthSettingsDialog));
}
};
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantId } = ownProps;
const conference = state['features/base/conference'].conference;
const participant
= participantId ? getParticipantById(state, participantId) : getLocalParticipant(state);
const { isNarrowLayout } = state['features/base/responsive-ui'];
const tracks = state['features/base/tracks'];
const audioTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantId);
let videoTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantId);
if (isScreenShareParticipant(participant)) {
videoTrack = getVirtualScreenshareParticipantTrack(tracks, participant?.id ?? '');
}
const _isConnectionStatusInactive = isTrackStreamingStatusInactive(videoTrack);
const _isConnectionStatusInterrupted = isTrackStreamingStatusInterrupted(videoTrack);
return {
_audioSsrc: audioTrack ? conference?.getSsrcByTrack(audioTrack.jitsiTrack) : undefined,
_disableShowMoreStats: Boolean(state['features/base/config'].disableShowMoreStats),
_enableAssumedBandwidth: state['features/base/config'].testing?.assumeBandwidth,
_enableSaveLogs: Boolean(state['features/base/config'].enableSaveLogs),
_isConnectionStatusInactive,
_isConnectionStatusInterrupted,
_isE2EEVerified: participant?.e2eeVerified,
_isNarrowLayout: isNarrowLayout,
_isVirtualScreenshareParticipant: isScreenShareParticipant(participant),
_isLocalVideo: Boolean(participant?.local),
_region: participant?.region,
_videoSsrc: videoTrack ? conference?.getSsrcByTrack(videoTrack.jitsiTrack) : undefined
};
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ConnectionIndicatorContent));

View File

@@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconConnection, IconConnectionInactive } from '../../../base/icons/svg';
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
import { trackStreamingStatusChanged } from '../../../base/tracks/actions.web';
import { ITrack } from '../../../base/tracks/types';
interface IProps {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<'icon' | 'inactiveIcon', string>>;
/**
* A CSS class that interprets the current connection status as a color.
*/
colorClass: string;
/**
* Disable/enable inactive indicator.
*/
connectionIndicatorInactiveDisabled: boolean;
/**
* Whether or not the connection status is inactive.
*/
isConnectionStatusInactive: boolean;
/**
* Whether or not the connection status is interrupted.
*/
isConnectionStatusInterrupted?: boolean;
/**
* JitsiTrack instance.
*/
track?: ITrack;
}
export const ConnectionIndicatorIcon = ({
classes,
colorClass,
connectionIndicatorInactiveDisabled,
isConnectionStatusInactive,
isConnectionStatusInterrupted,
track
}: IProps) => {
const { cx } = useStyles();
const dispatch = useDispatch();
const sourceName = track?.jitsiTrack?.getSourceName();
const handleTrackStreamingStatusChanged = (jitsiTrack: any, streamingStatus: string) => {
dispatch(trackStreamingStatusChanged(jitsiTrack, streamingStatus));
};
// TODO: replace this with a custom hook to be reused where track streaming status is needed.
// TODO: In the hood the listener should updates a local track streaming status instead of that in redux store.
useEffect(() => {
if (track && !track.local) {
track.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED, handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(track.jitsiTrack, track.jitsiTrack.getTrackStreamingStatus?.()));
}
return () => {
if (track && !track.local) {
track.jitsiTrack.off(
JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
handleTrackStreamingStatusChanged
);
dispatch(trackStreamingStatusChanged(track.jitsiTrack, track.jitsiTrack.getTrackStreamingStatus?.()));
}
};
}, [ sourceName ]);
if (isConnectionStatusInactive) {
if (connectionIndicatorInactiveDisabled) {
return null;
}
return (
<span className = 'connection_ninja'>
<Icon
className = { cx(classes?.icon, classes?.inactiveIcon, colorClass) }
size = { 24 }
src = { IconConnectionInactive } />
</span>
);
}
let emptyIconWrapperClassName = 'connection_empty';
if (isConnectionStatusInterrupted) {
// emptyIconWrapperClassName is used by the torture tests to identify lost connection status handling.
emptyIconWrapperClassName = 'connection_lost';
}
return (
<span className = { emptyIconWrapperClassName }>
<Icon
className = { cx(classes?.icon, colorClass) }
size = { 16 }
src = { IconConnection } />
</span>
);
};

View File

@@ -0,0 +1,38 @@
import { JitsiTrackStreamingStatus } from '../base/lib-jitsi-meet';
import { ITrack } from '../base/tracks/types';
/**
* Checks if the passed track's streaming status is active.
*
* @param {Object} videoTrack - Track reference.
* @returns {boolean} - Is streaming status active.
*/
export function isTrackStreamingStatusActive(videoTrack?: ITrack) {
const streamingStatus = videoTrack?.streamingStatus;
return streamingStatus === JitsiTrackStreamingStatus.ACTIVE;
}
/**
* Checks if the passed track's streaming status is inactive.
*
* @param {Object} videoTrack - Track reference.
* @returns {boolean} - Is streaming status inactive.
*/
export function isTrackStreamingStatusInactive(videoTrack?: ITrack) {
const streamingStatus = videoTrack?.streamingStatus;
return streamingStatus === JitsiTrackStreamingStatus.INACTIVE;
}
/**
* Checks if the passed track's streaming status is interrupted.
*
* @param {Object} videoTrack - Track reference.
* @returns {boolean} - Is streaming status interrupted.
*/
export function isTrackStreamingStatusInterrupted(videoTrack?: ITrack) {
const streamingStatus = videoTrack?.streamingStatus;
return streamingStatus === JitsiTrackStreamingStatus.INTERRUPTED;
}

View File

@@ -0,0 +1,23 @@
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import statsEmitter from './statsEmitter';
/**
* Implements the middleware of the feature connection-indicator.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case CONFERENCE_JOINED: {
statsEmitter.startListeningForStats(action.conference);
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,201 @@
import { union } from 'lodash-es';
import { IJitsiConference } from '../base/conference/reducer';
import {
JitsiConnectionQualityEvents
} from '../base/lib-jitsi-meet';
import { trackCodecChanged } from '../base/tracks/actions.any';
import { getLocalTracks } from '../base/tracks/functions.any';
/**
* Contains all the callbacks to be notified when stats are updated.
*
* ```
* {
* userId: Function[]
* }
* ```
*/
const subscribers: any = {};
interface IStats {
codec?: Object;
framerate?: Object;
resolution?: Object;
}
/**
* A singleton that acts as a pub/sub service for connection stat updates.
*/
const statsEmitter = {
/**
* Have {@code statsEmitter} subscribe to stat updates from a given
* conference.
*
* @param {JitsiConference} conference - The conference for which
* {@code statsEmitter} should subscribe for stat updates.
* @returns {void}
*/
startListeningForStats(conference: IJitsiConference) {
conference.on(JitsiConnectionQualityEvents.LOCAL_STATS_UPDATED,
(stats: IStats) => this._onStatsUpdated(conference.myUserId(), stats));
conference.on(JitsiConnectionQualityEvents.REMOTE_STATS_UPDATED,
(id: string, stats: IStats) => this._emitStatsUpdate(id, stats));
},
/**
* Add a subscriber to be notified when stats are updated for a specified
* user id.
*
* @param {string} id - The user id whose stats updates are of interest.
* @param {Function} callback - The function to invoke when stats for the
* user have been updated.
* @returns {void}
*/
subscribeToClientStats(id: string | undefined, callback: Function) {
if (!id) {
return;
}
if (!subscribers[id]) {
subscribers[id] = [];
}
subscribers[id].push(callback);
},
/**
* Remove a subscriber that is listening for stats updates for a specified
* user id.
*
* @param {string} id - The user id whose stats updates are no longer of
* interest.
* @param {Function} callback - The function that is currently subscribed to
* stat updates for the specified user id.
* @returns {void}
*/
unsubscribeToClientStats(id: string, callback: Function) {
if (!subscribers[id]) {
return;
}
const filteredSubscribers = subscribers[id].filter(
(subscriber: Function) => subscriber !== callback);
if (filteredSubscribers.length) {
subscribers[id] = filteredSubscribers;
} else {
delete subscribers[id];
}
},
/**
* Emit a stat update to all those listening for a specific user's
* connection stats.
*
* @param {string} id - The user id the stats are associated with.
* @param {Object} stats - New connection stats for the user.
* @returns {void}
*/
_emitStatsUpdate(id: string, stats: IStats = {}) {
const callbacks = subscribers[id] || [];
callbacks.forEach((callback: Function) => {
callback(stats);
});
},
/**
* Emit a stat update to all those listening for local stat updates. Will
* also update listeners of remote user stats of changes related to their
* stats.
*
* @param {string} localUserId - The user id for the local user.
* @param {Object} stats - Connection stats for the local user as provided
* by the library.
* @returns {void}
*/
_onStatsUpdated(localUserId: string, stats: IStats) {
const allUserFramerates = stats.framerate || {};
const allUserResolutions = stats.resolution || {};
const allUserCodecs = stats.codec || {};
// FIXME resolution and framerate are maps keyed off of user ids with
// stat values. Receivers of stats expect resolution and framerate to
// be primitives, not maps, so here we override the 'lib-jitsi-meet'
// stats objects.
const modifiedLocalStats = Object.assign({}, stats, {
framerate: allUserFramerates[localUserId as keyof typeof allUserFramerates],
resolution: allUserResolutions[localUserId as keyof typeof allUserResolutions],
codec: allUserCodecs[localUserId as keyof typeof allUserCodecs]
});
modifiedLocalStats.codec
&& Object.keys(modifiedLocalStats.codec).length
&& this._updateLocalCodecs(modifiedLocalStats.codec);
this._emitStatsUpdate(localUserId, modifiedLocalStats);
// Get all the unique user ids from the framerate and resolution stats
// and update remote user stats as needed.
const framerateUserIds = Object.keys(allUserFramerates);
const resolutionUserIds = Object.keys(allUserResolutions);
const codecUserIds = Object.keys(allUserCodecs);
union(framerateUserIds, resolutionUserIds, codecUserIds)
.filter(id => id !== localUserId)
.forEach(id => {
const remoteUserStats: IStats = {};
const framerate = allUserFramerates[id as keyof typeof allUserFramerates];
if (framerate) {
remoteUserStats.framerate = framerate;
}
const resolution = allUserResolutions[id as keyof typeof allUserResolutions];
if (resolution) {
remoteUserStats.resolution = resolution;
}
const codec = allUserCodecs[id as keyof typeof allUserCodecs];
if (codec) {
remoteUserStats.codec = codec;
}
this._emitStatsUpdate(id, remoteUserStats);
});
},
/**
* Updates the codec associated with the local tracks.
* This is currently used for torture tests.
*
* @param {any} codecs - Codec information per local SSRC.
* @returns {void}
*/
_updateLocalCodecs(codecs: any) {
if (typeof APP !== 'undefined') {
const tracks = APP.store.getState()['features/base/tracks'];
const localTracks = getLocalTracks(tracks);
for (const track of localTracks) {
const ssrc = track.jitsiTrack?.getSsrc();
if (ssrc && Object.keys(codecs).find(key => Number(key) === ssrc)) {
const codecsPerSsrc = codecs[ssrc];
const codec = codecsPerSsrc.audio ?? codecsPerSsrc.video;
if (track.codec !== codec) {
APP.store.dispatch(trackCodecChanged(track.jitsiTrack, codec));
}
}
}
}
}
};
export default statsEmitter;