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,72 @@
/**
* The type of (redux) action which sets the maximum video height that should be
* received from remote participants for the large video, even if the user prefers a larger video
* height.
*
* {
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO,
* maxReceiverVideoQuality: number
* }
*/
export const SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO = 'SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO';
/**
* The type of (redux) action which sets the maximum video height that should be
* received from remote participants for the screen sharing filmstrip, even if the user prefers a larger video
* height.
*
* {
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP,
* maxReceiverVideoQuality: number
* }
*/
export const SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP = 'SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP';
/**
* The type of (redux) action which sets the maximum video height that should be
* received from remote participants for stage filmstrip, even if the user prefers a larger video
* height.
*
* {
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP,
* maxReceiverVideoQuality: number
* }
*/
export const SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP = 'SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP';
/**
* The type of (redux) action which sets the maximum video height that should be
* received from remote participants for tile view, even if the user prefers a larger video
* height.
*
* {
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW,
* maxReceiverVideoQuality: number
* }
*/
export const SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW = 'SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW';
/**
* The type of (redux) action which sets the maximum video height that should be
* received from remote participants for vertical filmstrip, even if the user prefers a larger video
* height.
*
* {
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP,
* maxReceiverVideoQuality: number
* }
*/
export const SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP = 'SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP';
/**
* The type of (redux) action which sets the preferred maximum video height that
* should be sent to and received from remote participants.
*
* {
* type: SET_PREFERRED_VIDEO_QUALITY,
* preferredVideoQuality: number
* }
*/
export const SET_PREFERRED_VIDEO_QUALITY = 'SET_PREFERRED_VIDEO_QUALITY';

View File

@@ -0,0 +1,135 @@
import { IStore } from '../app/types';
import {
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP,
SET_PREFERRED_VIDEO_QUALITY
} from './actionTypes';
import { MAX_VIDEO_QUALITY, VIDEO_QUALITY_LEVELS } from './constants';
import logger from './logger';
/**
* Sets the max frame height that should be received for the large video.
*
* @param {number} maxReceiverVideoQuality - The max video frame height to
* receive.
* @returns {{
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO,
* maxReceiverVideoQuality: number
* }}
*/
export function setMaxReceiverVideoQualityForLargeVideo(maxReceiverVideoQuality: number) {
return {
type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO,
maxReceiverVideoQuality
};
}
/**
* Sets the max frame height that should be received for the screen sharing filmstrip participant.
*
* @param {number} maxReceiverVideoQuality - The max video frame height to
* receive.
* @returns {{
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP,
* maxReceiverVideoQuality: number
* }}
*/
export function setMaxReceiverVideoQualityForScreenSharingFilmstrip(maxReceiverVideoQuality: number) {
return {
type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP,
maxReceiverVideoQuality
};
}
/**
* Sets the max frame height that should be received from remote videos for the stage filmstrip.
*
* @param {number} maxReceiverVideoQuality - The max video frame height to
* receive.
* @returns {{
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP,
* maxReceiverVideoQuality: number
* }}
*/
export function setMaxReceiverVideoQualityForStageFilmstrip(maxReceiverVideoQuality: number) {
return {
type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP,
maxReceiverVideoQuality
};
}
/**
* Sets the max frame height that should be received from remote videos in tile view.
*
* @param {number} maxReceiverVideoQuality - The max video frame height to
* receive.
* @returns {{
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW,
* maxReceiverVideoQuality: number
* }}
*/
export function setMaxReceiverVideoQualityForTileView(maxReceiverVideoQuality: number) {
return {
type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW,
maxReceiverVideoQuality
};
}
/**
* Sets the max frame height that should be received from remote videos for the vertical filmstrip.
*
* @param {number} maxReceiverVideoQuality - The max video frame height to
* receive.
* @returns {{
* type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP,
* maxReceiverVideoQuality: number
* }}
*/
export function setMaxReceiverVideoQualityForVerticalFilmstrip(maxReceiverVideoQuality: number) {
return {
type: SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP,
maxReceiverVideoQuality
};
}
/**
* Sets the max frame height the user prefers to send and receive from the
* remote participants.
*
* @param {number} preferredVideoQuality - The max video resolution to send and
* receive.
* @returns {{
* type: SET_PREFERRED_VIDEO_QUALITY,
* preferredVideoQuality: number
* }}
*/
export function setPreferredVideoQuality(preferredVideoQuality: number) {
return {
type: SET_PREFERRED_VIDEO_QUALITY,
preferredVideoQuality
};
}
/**
* Sets the maximum video size the local participant should send and receive from
* remote participants.
*
* @param {number} frameHeight - The user preferred max frame height for send and
* receive video.
* @returns {void}
*/
export function setVideoQuality(frameHeight: number) {
return (dispatch: IStore['dispatch']) => {
if (frameHeight < VIDEO_QUALITY_LEVELS.LOW) {
logger.error(`Invalid frame height for video quality - ${frameHeight}`);
return;
}
dispatch(setPreferredVideoQuality(Math.min(frameHeight, MAX_VIDEO_QUALITY)));
};
}

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
interface IProps {
/**
* The 'aria-label' text.
*/
ariaLabel: string;
/**
* The maximum value for slider value.
*/
max: number;
/**
* The minimum value for slider value.
*/
min: number;
/**
* Callback invoked on change.
*/
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
/**
* The granularity that the value must adhere to.
*/
step: number;
/**
* The current value where the knob is positioned.
*/
value: number;
}
const useStyles = makeStyles()(theme => {
// keep the same height for all elements:
// input, input track & fake track(div)
const height = 6;
const inputTrack = {
background: 'transparent',
height
};
const inputThumb = {
background: theme.palette.text01,
border: 0,
borderRadius: '50%',
height: 24,
width: 24
};
const focused = {
outline: `1px solid ${theme.palette.ui06}`
};
return {
sliderContainer: {
cursor: 'pointer',
width: '100%',
position: 'relative',
textAlign: 'center'
},
knobContainer: {
display: 'flex',
justifyContent: 'space-between',
marginLeft: 2,
marginRight: 2,
position: 'absolute',
width: '100%'
},
knob: {
background: theme.palette.text01,
borderRadius: '50%',
display: 'inline-block',
height,
width: 6
},
track: {
background: theme.palette.text03,
borderRadius: Number(theme.shape.borderRadius) / 2,
height
},
slider: {
// Use an additional class here to override global CSS specificity
'&.custom-slider': {
'-webkit-appearance': 'none',
background: 'transparent',
height,
left: 0,
position: 'absolute',
top: 0,
width: '100%',
'&.focus-visible': {
// override global styles in order to use our own color
outline: 'none !important',
'&::-webkit-slider-runnable-track': focused,
'&::ms-track': focused,
'&::-moz-range-track': focused
},
'&::-webkit-slider-runnable-track': {
'-webkit-appearance': 'none',
...inputTrack
},
'&::-webkit-slider-thumb': {
'-webkit-appearance': 'none',
position: 'relative',
top: -6,
...inputThumb
},
'&::ms-track': {
...inputTrack
},
'&::-ms-thumb': {
...inputThumb
},
'&::-moz-range-track': {
...inputTrack
},
'&::-moz-range-thumb': {
...inputThumb
}
}
}
};
});
/**
* Custom slider.
*
* @returns {ReactElement}
*/
function Slider({ ariaLabel, max, min, onChange, step, value }: IProps) {
const { classes, cx } = useStyles();
const knobs = [ ...Array(Math.floor((max - min) / step) + 1) ];
return (
<div className = { classes.sliderContainer }>
<ul
aria-hidden = { true }
className = { cx('empty-list', classes.knobContainer) }>
{knobs.map((_, i) => (
<li
className = { classes.knob }
key = { `knob-${i}` } />))}
</ul>
<div className = { classes.track } />
<input
aria-label = { ariaLabel }
className = { cx(classes.slider, 'custom-slider') }
max = { max }
min = { min }
onChange = { onChange }
step = { step }
type = 'range'
value = { value } />
</div>
);
}
export default Slider;

View File

@@ -0,0 +1,58 @@
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { openDialog } from '../../base/dialog/actions';
import { translate } from '../../base/i18n/functions';
import { IconPerformance } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import VideoQualityDialog from './VideoQualityDialog.web';
/**
* The type of the React {@code Component} props of
* {@link VideoQualityButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not audio only mode is currently enabled.
*/
_audioOnly: boolean;
/**
* The currently configured maximum quality resolution to be received from
* and sent to remote participants.
*/
_videoQuality: number;
}
/**
* React {@code Component} responsible for displaying a button in the overflow
* menu of the toolbar, including an icon showing the currently selected
* max receive quality.
*
* @augments Component
*/
class VideoQualityButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.callQuality';
override label = 'videoStatus.performanceSettings';
override tooltip = 'videoStatus.performanceSettings';
override icon = IconPerformance;
/**
* Handles clicking the button, and opens the video quality dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
sendAnalytics(createToolbarEvent('video.quality'));
dispatch(openDialog(VideoQualityDialog));
}
}
export default connect()(translate(VideoQualityButton));

View File

@@ -0,0 +1,30 @@
import React, { Component } from 'react';
import Dialog from '../../base/ui/components/web/Dialog';
import VideoQualitySlider from './VideoQualitySlider.web';
/**
* Implements a React {@link Component} which displays the component
* {@code VideoQualitySlider} in a dialog.
*
* @augments Component
*/
export default class VideoQualityDialog extends Component {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
cancel = {{ hidden: true }}
ok = {{ hidden: true }}
titleKey = 'videoStatus.performanceSettings'>
<VideoQualitySlider />
</Dialog>
);
}
}

View File

@@ -0,0 +1,34 @@
import { WithTranslation } from 'react-i18next';
import { translate } from '../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../base/label/components/native/ExpandedLabel';
import { AUD_LABEL_COLOR } from './styles';
type Props = AbstractProps & WithTranslation;
/**
* A react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code VideoQualityLabel}.
*/
class VideoQualityExpandedLabel extends ExpandedLabel<Props> {
/**
* Returns the color this expanded label should be rendered with.
*
* @returns {string}
*/
_getColor() {
return AUD_LABEL_COLOR;
}
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
return this.props.t('videoStatus.audioOnlyExpanded');
}
}
export default translate(VideoQualityExpandedLabel);

View File

@@ -0,0 +1,75 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../app/types';
import { translate } from '../../base/i18n/functions';
import Label from '../../base/label/components/native/Label';
import { StyleType, combineStyles } from '../../base/styles/functions.native';
import styles from './styles';
interface IProps extends WithTranslation {
/**
* Whether or not the conference is in audio only mode.
*/
_audioOnly: boolean;
/**
* Style of the component passed as props.
*/
style?: StyleType;
}
/**
* React {@code Component} responsible for displaying a label that indicates
* the displayed video state of the current conference.
*
* NOTE: Due to the lack of actual video quality information on mobile side,
* this component currently only displays audio only indicator, but the naming
* is kept consistent with web and in the future we may introduce the required
* api and extend this component with actual quality indication.
*/
class VideoQualityLabel extends Component<IProps> {
/**
* Implements React {@link Component}'s render.
*
* @inheritdoc
*/
override render() {
const { _audioOnly, style, t } = this.props;
if (!_audioOnly) {
// We don't have info about the quality so no need for the indicator
return null;
}
return (
<Label // @ts-ignore
style = { combineStyles(styles.indicatorAudioOnly, style) }
text = { t('videoStatus.audioOnly') } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code AbstractVideoQualityLabel}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _audioOnly: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { enabled: audioOnly } = state['features/base/audio-only'];
return {
_audioOnly: audioOnly
};
}
export default translate(connect(_mapStateToProps)(VideoQualityLabel));

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import { openDialog } from '../../base/dialog/actions';
import { IconPerformance } from '../../base/icons/svg';
import Label from '../../base/label/components/web/Label';
import { COLORS } from '../../base/label/constants';
import Tooltip from '../../base/tooltip/components/Tooltip';
import { shouldDisplayVideoQualityLabel } from '../selector';
import VideoQualityDialog from './VideoQualityDialog.web';
/**
* React {@code Component} responsible for displaying a label that indicates
* the displayed video state of the current conference. {@code AudioOnlyLabel}
* Will display when the conference is in audio only mode. {@code HDVideoLabel}
* Will display if not in audio only mode and a high-definition large video is
* being displayed.
*
* @returns {JSX}
*/
const VideoQualityLabel = () => {
const _audioOnly = useSelector((state: IReduxState) => state['features/base/audio-only'].enabled);
const _visible = useSelector(shouldDisplayVideoQualityLabel);
const dispatch = useDispatch();
const { t } = useTranslation();
if (!_visible) {
return null;
}
let className, icon, labelContent, tooltipKey;
if (_audioOnly) {
className = 'audio-only';
labelContent = t('videoStatus.audioOnly');
tooltipKey = 'videoStatus.labelTooltipAudioOnly';
} else {
className = 'current-video-quality';
icon = IconPerformance;
tooltipKey = 'videoStatus.performanceSettings';
}
const onClick = () => dispatch(openDialog(VideoQualityDialog));
return (
<Tooltip
content = { t(tooltipKey) }
position = { 'bottom' }>
<Label
accessibilityText = { t(tooltipKey) }
className = { className }
color = { COLORS.white }
icon = { icon }
iconColor = '#fff'
id = 'videoResolutionLabel'
// eslint-disable-next-line react/jsx-no-bind
onClick = { onClick }
text = { labelContent } />
</Tooltip>
);
};
export default VideoQualityLabel;

View File

@@ -0,0 +1,385 @@
import { Theme } from '@mui/material';
import clsx from 'clsx';
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IReduxState, IStore } from '../../app/types';
import { setAudioOnly } from '../../base/audio-only/actions';
import { translate } from '../../base/i18n/functions';
import { setLastN } from '../../base/lastn/actions';
import { getLastNForQualityLevel } from '../../base/lastn/functions';
import { setPreferredVideoQuality } from '../actions';
import { DEFAULT_LAST_N, VIDEO_QUALITY_LEVELS } from '../constants';
import logger from '../logger';
import Slider from './Slider.web';
const {
ULTRA,
HIGH,
STANDARD,
LOW
} = VIDEO_QUALITY_LEVELS;
/**
* Creates an analytics event for a press of one of the buttons in the video
* quality dialog.
*
* @param {string} quality - The quality which was selected.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
const createEvent = function(quality: string) {
return createToolbarEvent(
'video.quality',
{
quality
});
};
/**
* The type of the React {@code Component} props of {@link VideoQualitySlider}.
*/
interface IProps extends WithTranslation {
/**
* Whether or not the conference is in audio only mode.
*/
_audioOnly: Boolean;
/**
* The channelLastN value configured for the conference.
*/
_channelLastN?: number;
/**
* Whether or not the conference is in peer to peer mode.
*/
_p2p?: Object;
/**
* The currently configured maximum quality resolution to be sent and
* received from the remote participants.
*/
_sendrecvVideoQuality: number;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Invoked to request toggling of audio only mode.
*/
dispatch: IStore['dispatch'];
}
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current UI theme.
*
* @returns {Object}
*/
const styles = (theme: Theme) => {
return {
dialog: {
color: theme.palette.text01
},
dialogDetails: {
...theme.typography.bodyShortRegularLarge,
marginBottom: 16
},
dialogContents: {
background: theme.palette.ui01,
padding: '16px 16px 48px 16px'
},
sliderDescription: {
...theme.typography.heading6,
display: 'flex',
justifyContent: 'space-between',
marginBottom: 40
}
};
};
/**
* Implements a React {@link Component} which displays a slider for selecting a
* new receive video quality.
*
* @augments Component
*/
class VideoQualitySlider extends Component<IProps> {
_sliderOptions: Array<{
audioOnly?: boolean;
onSelect: Function;
textKey: string;
videoQuality?: number;
}>;
/**
* Initializes a new {@code VideoQualitySlider} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._enableAudioOnly = this._enableAudioOnly.bind(this);
this._enableHighDefinition = this._enableHighDefinition.bind(this);
this._enableLowDefinition = this._enableLowDefinition.bind(this);
this._enableStandardDefinition
= this._enableStandardDefinition.bind(this);
this._enableUltraHighDefinition = this._enableUltraHighDefinition.bind(this);
this._onSliderChange = this._onSliderChange.bind(this);
/**
* An array of configuration options for displaying a choice in the
* input. The onSelect callback will be invoked when the option is
* selected and videoQuality helps determine which choice matches with
* the currently active quality level.
*
* @private
* @type {Object[]}
*/
this._sliderOptions = [
{
audioOnly: true,
onSelect: this._enableAudioOnly,
textKey: 'audioOnly.audioOnly'
},
{
onSelect: this._enableLowDefinition,
textKey: 'videoStatus.lowDefinition',
videoQuality: LOW
},
{
onSelect: this._enableStandardDefinition,
textKey: 'videoStatus.standardDefinition',
videoQuality: STANDARD
},
{
onSelect: this._enableUltraHighDefinition,
textKey: 'videoStatus.highDefinition',
videoQuality: ULTRA
}
];
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { t } = this.props;
const classes = withStyles.getClasses(this.props);
const activeSliderOption = this._mapCurrentQualityToSliderValue();
return (
<div className = { clsx('video-quality-dialog', classes.dialog) }>
<div
aria-hidden = { true }
className = { classes.dialogDetails }>
{t('videoStatus.adjustFor')}
</div>
<div className = { classes.dialogContents }>
<div
aria-hidden = { true }
className = { classes.sliderDescription }>
<span>{t('videoStatus.bestPerformance')}</span>
<span>{t('videoStatus.highestQuality')}</span>
</div>
<Slider
ariaLabel = { t('videoStatus.callQuality') }
max = { this._sliderOptions.length - 1 }
min = { 0 }
onChange = { this._onSliderChange }
step = { 1 }
value = { activeSliderOption } />
</div>
</div>
);
}
/**
* Dispatches an action to enable audio only mode.
*
* @private
* @returns {void}
*/
_enableAudioOnly() {
sendAnalytics(createEvent('audio.only'));
logger.log('Video quality: audio only enabled');
this.props.dispatch(setAudioOnly(true));
}
/**
* Handles the action of the high definition video being selected.
* Dispatches an action to receive high quality video from remote
* participants.
*
* @private
* @returns {void}
*/
_enableHighDefinition() {
sendAnalytics(createEvent('high'));
logger.log('Video quality: high enabled');
this._setPreferredVideoQuality(HIGH);
}
/**
* Dispatches an action to receive low quality video from remote
* participants.
*
* @private
* @returns {void}
*/
_enableLowDefinition() {
sendAnalytics(createEvent('low'));
logger.log('Video quality: low enabled');
this._setPreferredVideoQuality(LOW);
}
/**
* Dispatches an action to receive standard quality video from remote
* participants.
*
* @private
* @returns {void}
*/
_enableStandardDefinition() {
sendAnalytics(createEvent('standard'));
logger.log('Video quality: standard enabled');
this._setPreferredVideoQuality(STANDARD);
}
/**
* Dispatches an action to receive ultra HD quality video from remote
* participants.
*
* @private
* @returns {void}
*/
_enableUltraHighDefinition() {
sendAnalytics(createEvent('ultra high'));
logger.log('Video quality: ultra high enabled');
this._setPreferredVideoQuality(ULTRA);
}
/**
* Matches the current video quality state with corresponding index of the
* component's slider options.
*
* @private
* @returns {void}
*/
_mapCurrentQualityToSliderValue() {
const { _audioOnly, _sendrecvVideoQuality } = this.props;
const { _sliderOptions } = this;
if (_audioOnly) {
const audioOnlyOption = _sliderOptions.find(
({ audioOnly }) => audioOnly);
// @ts-ignore
return _sliderOptions.indexOf(audioOnlyOption);
}
for (let i = 0; i < _sliderOptions.length; i++) {
if (Number(_sliderOptions[i].videoQuality) >= _sendrecvVideoQuality) {
return i;
}
}
return -1;
}
/**
* Invokes a callback when the selected video quality changes.
*
* @param {Object} event - The slider's change event.
* @private
* @returns {void}
*/
_onSliderChange(event: React.ChangeEvent<HTMLInputElement>) {
const { _audioOnly, _sendrecvVideoQuality } = this.props;
const {
// @ts-ignore
audioOnly,
// @ts-ignore
onSelect,
// @ts-ignore
videoQuality
} = this._sliderOptions[event.target.value as keyof typeof this._sliderOptions];
// Take no action if the newly chosen option does not change audio only
// or video quality state.
if ((_audioOnly && audioOnly)
|| (!_audioOnly && videoQuality === _sendrecvVideoQuality)) {
return;
}
onSelect();
}
/**
* Helper for changing the preferred maximum video quality to receive and
* disable audio only.
*
* @param {number} qualityLevel - The new maximum video quality. Should be
* a value enumerated in {@code VIDEO_QUALITY_LEVELS}.
* @private
* @returns {void}
*/
_setPreferredVideoQuality(qualityLevel: number) {
this.props.dispatch(setPreferredVideoQuality(qualityLevel));
if (this.props._audioOnly) {
this.props.dispatch(setAudioOnly(false));
}
// Determine the lastN value based on the quality setting.
let { _channelLastN = DEFAULT_LAST_N } = this.props;
_channelLastN = _channelLastN === -1 ? DEFAULT_LAST_N : _channelLastN;
const lastN = getLastNForQualityLevel(qualityLevel, _channelLastN);
// Set the lastN for the conference.
this.props.dispatch(setLastN(lastN));
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code VideoQualitySlider} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { enabled: audioOnly } = state['features/base/audio-only'];
const { p2p } = state['features/base/conference'];
const { preferredVideoQuality } = state['features/video-quality'];
const { channelLastN } = state['features/base/config'];
return {
_audioOnly: audioOnly,
_channelLastN: channelLastN,
_p2p: p2p,
_sendrecvVideoQuality: preferredVideoQuality
};
}
export default translate(connect(_mapStateToProps)(withStyles(VideoQualitySlider, styles)));

View File

@@ -0,0 +1,20 @@
import { ColorPalette } from '../../base/styles/components/styles/ColorPalette';
import { createStyleSheet } from '../../base/styles/functions.any';
import BaseTheme from '../../base/ui/components/BaseTheme.native';
export const AUD_LABEL_COLOR = ColorPalette.green;
/**
* The styles of the React {@code Components} of the feature video-quality.
*/
export default createStyleSheet({
/**
* Style for the audio-only indicator.
*/
indicatorAudioOnly: {
backgroundColor: AUD_LABEL_COLOR,
borderRadius: BaseTheme.shape.borderRadius,
height: 32
}
});

View File

@@ -0,0 +1,55 @@
/**
* Default last-n value used to be used for "HD" video quality setting when no channelLastN value is specified.
*
* @type {number}
*/
export const DEFAULT_LAST_N = 20;
/**
* The supported video codecs.
*
* @type {enum}
*/
export enum VIDEO_CODEC {
AV1 = 'av1',
H264 = 'h264',
VP8 = 'vp8',
VP9 = 'vp9'
}
/**
* The supported remote video resolutions. The values are currently based on
* available simulcast layers.
*
* @type {object}
*/
export const VIDEO_QUALITY_LEVELS = {
ULTRA: 2160,
HIGH: 720,
STANDARD: 360,
LOW: 180,
NONE: 0
};
/**
* Indicates unlimited video quality.
*/
export const VIDEO_QUALITY_UNLIMITED = -1;
/**
* The maximum video quality from the VIDEO_QUALITY_LEVELS map.
*/
export const MAX_VIDEO_QUALITY = Math.max(...Object.values(VIDEO_QUALITY_LEVELS));
/**
* Maps quality level names used in the config.videoQuality.minHeightForQualityLvl to the quality level constants used
* by the application.
*
* @type {Object}
*/
export const CFG_LVL_TO_APP_QUALITY_LVL = {
'low': VIDEO_QUALITY_LEVELS.LOW,
'standard': VIDEO_QUALITY_LEVELS.STANDARD,
'high': VIDEO_QUALITY_LEVELS.HIGH,
'ultra': VIDEO_QUALITY_LEVELS.ULTRA
};

View File

@@ -0,0 +1,65 @@
import { CFG_LVL_TO_APP_QUALITY_LVL, VIDEO_QUALITY_LEVELS } from './constants';
/**
* Selects {@code VIDEO_QUALITY_LEVELS} for the given {@link availableHeight} and threshold to quality mapping.
*
* @param {number} availableHeight - The height to which a matching video quality level should be found.
* @param {Map<number, number>} heightToLevel - The threshold to quality level mapping. The keys are sorted in the
* ascending order.
* @returns {number} The matching value from {@code VIDEO_QUALITY_LEVELS}.
*/
export function getReceiverVideoQualityLevel(availableHeight: number, heightToLevel: Map<number, number>): number {
let selectedLevel = VIDEO_QUALITY_LEVELS.LOW;
for (const [ levelThreshold, level ] of heightToLevel.entries()) {
if (availableHeight >= levelThreshold) {
selectedLevel = level;
}
}
return selectedLevel;
}
/**
* Converts {@code Object} passed in the config which represents height thresholds to vide quality level mapping to
* a {@code Map}.
*
* @param {Object} minHeightForQualityLvl - The 'config.videoQuality.minHeightForQualityLvl' Object from
* the configuration. See config.js for more details.
* @returns {Map<number, number>|undefined} - A mapping of minimal thumbnail height required for given quality level or
* {@code undefined} if the map contains invalid values.
*/
export function validateMinHeightForQualityLvl(minHeightForQualityLvl?: { [key: number]: string; }) {
if (typeof minHeightForQualityLvl !== 'object'
|| Object.keys(minHeightForQualityLvl).map(lvl => Number(lvl))
.find(lvl => lvl === null || isNaN(lvl) || lvl < 0)) {
return undefined;
}
const levelsSorted
= Object.keys(minHeightForQualityLvl)
.map(k => Number(k))
.sort((a, b) => a - b);
const map = new Map();
Object.values(VIDEO_QUALITY_LEVELS).sort()
.forEach(value => {
if (value > VIDEO_QUALITY_LEVELS.NONE) {
map.set(value, value);
}
});
for (const level of levelsSorted) {
const configQuality = minHeightForQualityLvl[level];
const appQuality = CFG_LVL_TO_APP_QUALITY_LVL[configQuality as keyof typeof CFG_LVL_TO_APP_QUALITY_LVL];
if (!appQuality) {
return undefined;
}
map.delete(appQuality);
map.set(level, appQuality);
}
return map;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/video-quality');

View File

@@ -0,0 +1,45 @@
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import { SET_CONFIG } from '../base/config/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { setPreferredVideoQuality } from './actions';
import logger from './logger';
import './subscriber';
/**
* Implements the middleware of the feature video-quality.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const result = next(action);
switch (action.type) {
case CONFERENCE_JOINED: {
if (navigator.product === 'ReactNative') {
const { resolution } = getState()['features/base/config'];
if (typeof resolution !== 'undefined') {
dispatch(setPreferredVideoQuality(Number.parseInt(`${resolution}`, 10)));
logger.info(`Configured preferred receiver video frame height to: ${resolution}`);
}
}
break;
}
case SET_CONFIG: {
const state = getState();
const { videoQuality = {} } = state['features/base/config'];
const { persistedPrefferedVideoQuality } = state['features/video-quality-persistent-storage'];
if (videoQuality.persist && typeof persistedPrefferedVideoQuality !== 'undefined') {
dispatch(setPreferredVideoQuality(persistedPrefferedVideoQuality));
}
break;
}
}
return result;
});

View File

@@ -0,0 +1,130 @@
import { SET_CONFIG } from '../base/config/actionTypes';
import { IConfig } from '../base/config/configType';
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { set } from '../base/redux/functions';
import {
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW,
SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP,
SET_PREFERRED_VIDEO_QUALITY
} from './actionTypes';
import { VIDEO_QUALITY_LEVELS } from './constants';
import { validateMinHeightForQualityLvl } from './functions';
import logger from './logger';
const DEFAULT_STATE = {
maxReceiverVideoQualityForLargeVideo: VIDEO_QUALITY_LEVELS.ULTRA,
maxReceiverVideoQualityForScreenSharingFilmstrip: VIDEO_QUALITY_LEVELS.HIGH,
maxReceiverVideoQualityForStageFilmstrip: VIDEO_QUALITY_LEVELS.HIGH,
maxReceiverVideoQualityForTileView: VIDEO_QUALITY_LEVELS.STANDARD,
maxReceiverVideoQualityForVerticalFilmstrip: VIDEO_QUALITY_LEVELS.LOW,
minHeightForQualityLvl: new Map(),
preferredVideoQuality: VIDEO_QUALITY_LEVELS.ULTRA
};
Object.values(VIDEO_QUALITY_LEVELS).sort()
.forEach(value => {
if (value > VIDEO_QUALITY_LEVELS.NONE) {
DEFAULT_STATE.minHeightForQualityLvl.set(value, value);
}
});
export interface IVideoQualityState {
maxReceiverVideoQualityForLargeVideo: number;
maxReceiverVideoQualityForScreenSharingFilmstrip: number;
maxReceiverVideoQualityForStageFilmstrip: number;
maxReceiverVideoQualityForTileView: number;
maxReceiverVideoQualityForVerticalFilmstrip: number;
minHeightForQualityLvl: Map<number, number>;
preferredVideoQuality: number;
}
export interface IVideoQualityPersistedState {
persistedPrefferedVideoQuality?: number;
}
// When the persisted state is initialized the current state (for example the default state) is erased.
// In order to workaround this issue we need additional state for the persisted properties.
PersistenceRegistry.register('features/video-quality-persistent-storage');
ReducerRegistry.register<IVideoQualityPersistedState>('features/video-quality-persistent-storage',
(state = {}, action): IVideoQualityPersistedState => {
switch (action.type) {
case SET_PREFERRED_VIDEO_QUALITY: {
const { preferredVideoQuality } = action;
return {
...state,
persistedPrefferedVideoQuality: preferredVideoQuality
};
}
}
return state;
});
ReducerRegistry.register<IVideoQualityState>('features/video-quality',
(state = DEFAULT_STATE, action): IVideoQualityState => {
switch (action.type) {
case SET_CONFIG:
return _setConfig(state, action);
case SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_LARGE_VIDEO:
return set(state,
'maxReceiverVideoQualityForLargeVideo',
action.maxReceiverVideoQuality);
case SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_SCREEN_SHARING_FILMSTRIP:
return set(state,
'maxReceiverVideoQualityForScreenSharingFilmstrip',
action.maxReceiverVideoQuality);
case SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_STAGE_FILMSTRIP:
return set(
state,
'maxReceiverVideoQualityForStageFilmstrip',
action.maxReceiverVideoQuality);
case SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_TILE_VIEW:
return set(
state,
'maxReceiverVideoQualityForTileView',
action.maxReceiverVideoQuality);
case SET_MAX_RECEIVER_VIDEO_QUALITY_FOR_VERTICAL_FILMSTRIP:
return set(
state,
'maxReceiverVideoQualityForVerticalFilmstrip',
action.maxReceiverVideoQuality);
case SET_PREFERRED_VIDEO_QUALITY: {
const { preferredVideoQuality } = action;
return {
...state,
preferredVideoQuality
};
}
}
return state;
});
/**
* Extracts the height to quality level mapping from the new config.
*
* @param {Object} state - The Redux state of feature base/lastn.
* @param {Action} action - The Redux action SET_CONFIG to reduce.
* @private
* @returns {Object} The new state after the reduction of the specified action.
*/
function _setConfig(state: IVideoQualityState, { config }: { config: IConfig; }) {
const configuredMap = config?.videoQuality?.minHeightForQualityLvl;
const convertedMap = validateMinHeightForQualityLvl(configuredMap);
if (configuredMap && !convertedMap) {
logger.error('Invalid config value videoQuality.minHeightForQualityLvl');
}
return convertedMap ? set(state, 'minHeightForQualityLvl', convertedMap) : state;
}

View File

@@ -0,0 +1,31 @@
import { IReduxState } from '../app/types';
import { isNarrowScreenWithChatOpen } from '../base/responsive-ui/functions';
import { shouldDisplayTileView } from '../video-layout/functions.any';
/**
* Selects the thumbnail height to the quality level mapping from the config.
*
* @param {Object} state - The redux state.
* @returns {Map<number,number>}
*/
export function getMinHeightForQualityLvlMap(state: IReduxState): Map<number, number> {
return state['features/video-quality'].minHeightForQualityLvl;
}
/**
* Determines whether the video quality label should be displayed.
*
* @param {IReduxState} state - The current Redux state of the application.
* @returns {boolean} - True if the video quality label should be displayed, otherwise false.
*/
export function shouldDisplayVideoQualityLabel(state: IReduxState): boolean {
const hideVideoQualityLabel
= shouldDisplayTileView(state)
|| interfaceConfig.VIDEO_QUALITY_LABEL_DISABLED
// Hide the video quality label for desktop browser if the chat is open and there isn't enough space
// to display it.
|| isNarrowScreenWithChatOpen(state);
return !hideVideoQualityLabel;
}

View File

@@ -0,0 +1,517 @@
import { debounce } from 'lodash-es';
import { IReduxState, IStore } from '../app/types';
import { _handleParticipantError } from '../base/conference/functions';
import { getSsrcRewritingFeatureFlag } from '../base/config/functions.any';
import { MEDIA_TYPE, VIDEO_TYPE } from '../base/media/constants';
import {
getLocalParticipant,
getSourceNamesByMediaTypeAndParticipant,
getSourceNamesByVideoTypeAndParticipant
} from '../base/participants/functions';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { getTrackSourceNameByMediaTypeAndParticipant } from '../base/tracks/functions';
import { reportError } from '../base/util/helpers';
import {
getActiveParticipantsIds,
getScreenshareFilmstripParticipantId,
isTopPanelEnabled
} from '../filmstrip/functions';
import { LAYOUTS } from '../video-layout/constants';
import {
getCurrentLayout,
getVideoQualityForLargeVideo,
getVideoQualityForResizableFilmstripThumbnails,
getVideoQualityForScreenSharingFilmstrip,
getVideoQualityForStageThumbnails,
shouldDisplayTileView
} from '../video-layout/functions';
import {
setMaxReceiverVideoQualityForLargeVideo,
setMaxReceiverVideoQualityForScreenSharingFilmstrip,
setMaxReceiverVideoQualityForStageFilmstrip,
setMaxReceiverVideoQualityForTileView,
setMaxReceiverVideoQualityForVerticalFilmstrip
} from './actions';
import { MAX_VIDEO_QUALITY, VIDEO_QUALITY_LEVELS, VIDEO_QUALITY_UNLIMITED } from './constants';
import { getReceiverVideoQualityLevel } from './functions';
import logger from './logger';
import { getMinHeightForQualityLvlMap } from './selector';
/**
* Handles changes in the visible participants in the filmstrip. The listener is debounced
* so that the client doesn't end up sending too many bridge messages when the user is
* scrolling through the thumbnails prompting updates to the selected endpoints.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/filmstrip'].visibleRemoteParticipants,
/* listener */ debounce((visibleRemoteParticipants, store) => {
_updateReceiverVideoConstraints(store);
}, 100));
StateListenerRegistry.register(
/* selector */ state => state['features/base/tracks'],
/* listener */(remoteTracks, store) => {
_updateReceiverVideoConstraints(store);
});
/**
* Handles the use case when the on-stage participant has changed.
*/
StateListenerRegistry.register(
state => state['features/large-video'].participantId,
(participantId, store) => {
_updateReceiverVideoConstraints(store);
}
);
/**
* Handles the use case when we have set some of the constraints in redux but the conference object wasn't available
* and we haven't been able to pass the constraints to lib-jitsi-meet.
*/
StateListenerRegistry.register(
state => state['features/base/conference'].conference,
(conference, store) => {
_updateReceiverVideoConstraints(store);
}
);
/**
* StateListenerRegistry provides a reliable way of detecting changes to
* lastn state and dispatching additional actions.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/lastn'].lastN,
/* listener */ (lastN, store) => {
_updateReceiverVideoConstraints(store);
});
/**
* Updates the receiver constraints when the stage participants change.
*/
StateListenerRegistry.register(
state => getActiveParticipantsIds(state).sort(),
(_, store) => {
_updateReceiverVideoConstraints(store);
}, {
deepEquals: true
}
);
/**
* Updates the receiver constraints when new video sources are added to the conference.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].remoteVideoSources,
/* listener */ (remoteVideoSources, store) => {
getSsrcRewritingFeatureFlag(store.getState()) && _updateReceiverVideoConstraints(store);
});
/**
* StateListenerRegistry provides a reliable way of detecting changes to
* maxReceiverVideoQuality* and preferredVideoQuality state and dispatching additional actions.
*/
StateListenerRegistry.register(
/* selector */ state => {
const {
maxReceiverVideoQualityForLargeVideo,
maxReceiverVideoQualityForScreenSharingFilmstrip,
maxReceiverVideoQualityForStageFilmstrip,
maxReceiverVideoQualityForTileView,
maxReceiverVideoQualityForVerticalFilmstrip,
preferredVideoQuality
} = state['features/video-quality'];
return {
maxReceiverVideoQualityForLargeVideo,
maxReceiverVideoQualityForScreenSharingFilmstrip,
maxReceiverVideoQualityForStageFilmstrip,
maxReceiverVideoQualityForTileView,
maxReceiverVideoQualityForVerticalFilmstrip,
preferredVideoQuality
};
},
/* listener */ (currentState, store, previousState = {}) => {
const { preferredVideoQuality } = currentState;
const changedPreferredVideoQuality = preferredVideoQuality !== previousState.preferredVideoQuality;
if (changedPreferredVideoQuality) {
_setSenderVideoConstraint(preferredVideoQuality, store);
typeof APP !== 'undefined' && APP.API.notifyVideoQualityChanged(preferredVideoQuality);
}
_updateReceiverVideoConstraints(store);
}, {
deepEquals: true
});
/**
* Implements a state listener in order to calculate max receiver video quality.
*/
StateListenerRegistry.register(
/* selector */ state => {
const { reducedUI } = state['features/base/responsive-ui'];
const _shouldDisplayTileView = shouldDisplayTileView(state);
const tileViewThumbnailSize = state['features/filmstrip']?.tileViewDimensions?.thumbnailSize;
const { visibleRemoteParticipants } = state['features/filmstrip'];
const { height: largeVideoHeight } = state['features/large-video'];
const activeParticipantsIds = getActiveParticipantsIds(state);
const {
screenshareFilmstripDimensions: {
thumbnailSize
}
} = state['features/filmstrip'];
const screenshareFilmstripParticipantId = getScreenshareFilmstripParticipantId(state);
return {
activeParticipantsCount: activeParticipantsIds?.length,
displayTileView: _shouldDisplayTileView,
largeVideoHeight,
participantCount: visibleRemoteParticipants?.size || 0,
reducedUI,
screenSharingFilmstripHeight:
screenshareFilmstripParticipantId && getCurrentLayout(state) === LAYOUTS.STAGE_FILMSTRIP_VIEW
? thumbnailSize?.height : undefined,
stageFilmstripThumbnailHeight: state['features/filmstrip'].stageFilmstripDimensions?.thumbnailSize?.height,
tileViewThumbnailHeight: tileViewThumbnailSize?.height,
verticalFilmstripThumbnailHeight:
state['features/filmstrip'].verticalViewDimensions?.gridView?.thumbnailSize?.height
};
},
/* listener */ ({
activeParticipantsCount,
displayTileView,
largeVideoHeight,
participantCount,
reducedUI,
screenSharingFilmstripHeight,
stageFilmstripThumbnailHeight,
tileViewThumbnailHeight,
verticalFilmstripThumbnailHeight
}, store, previousState = {}) => {
const { dispatch, getState } = store;
const state = getState();
const {
maxReceiverVideoQualityForLargeVideo,
maxReceiverVideoQualityForScreenSharingFilmstrip,
maxReceiverVideoQualityForStageFilmstrip,
maxReceiverVideoQualityForTileView,
maxReceiverVideoQualityForVerticalFilmstrip
} = state['features/video-quality'];
const { maxFullResolutionParticipants = 2 } = state['features/base/config'];
let maxVideoQualityChanged = false;
if (displayTileView) {
let newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.STANDARD;
if (reducedUI) {
newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.LOW;
} else if (typeof tileViewThumbnailHeight === 'number' && !Number.isNaN(tileViewThumbnailHeight)) {
newMaxRecvVideoQuality
= getReceiverVideoQualityLevel(tileViewThumbnailHeight, getMinHeightForQualityLvlMap(state));
// Override HD level calculated for the thumbnail height when # of participants threshold is exceeded
if (maxFullResolutionParticipants !== -1) {
const override
= participantCount > maxFullResolutionParticipants
&& newMaxRecvVideoQuality > VIDEO_QUALITY_LEVELS.STANDARD;
logger.info(`Video quality level for thumbnail height: ${tileViewThumbnailHeight}, `
+ `is: ${newMaxRecvVideoQuality}, `
+ `override: ${String(override)}, `
+ `max full res N: ${maxFullResolutionParticipants}`);
if (override) {
newMaxRecvVideoQuality = VIDEO_QUALITY_LEVELS.STANDARD;
}
}
}
if (maxReceiverVideoQualityForTileView !== newMaxRecvVideoQuality) {
maxVideoQualityChanged = true;
dispatch(setMaxReceiverVideoQualityForTileView(newMaxRecvVideoQuality));
}
} else {
let newMaxRecvVideoQualityForStageFilmstrip;
let newMaxRecvVideoQualityForVerticalFilmstrip;
let newMaxRecvVideoQualityForLargeVideo;
let newMaxRecvVideoQualityForScreenSharingFilmstrip;
if (reducedUI) {
newMaxRecvVideoQualityForVerticalFilmstrip
= newMaxRecvVideoQualityForStageFilmstrip
= newMaxRecvVideoQualityForLargeVideo
= newMaxRecvVideoQualityForScreenSharingFilmstrip
= VIDEO_QUALITY_LEVELS.LOW;
} else {
newMaxRecvVideoQualityForStageFilmstrip
= getVideoQualityForStageThumbnails(stageFilmstripThumbnailHeight, state);
newMaxRecvVideoQualityForVerticalFilmstrip
= getVideoQualityForResizableFilmstripThumbnails(verticalFilmstripThumbnailHeight, state);
newMaxRecvVideoQualityForLargeVideo = getVideoQualityForLargeVideo(largeVideoHeight);
newMaxRecvVideoQualityForScreenSharingFilmstrip
= getVideoQualityForScreenSharingFilmstrip(screenSharingFilmstripHeight, state);
// Override HD level calculated for the thumbnail height when # of participants threshold is exceeded
if (maxFullResolutionParticipants !== -1) {
if (activeParticipantsCount > 0
&& newMaxRecvVideoQualityForStageFilmstrip > VIDEO_QUALITY_LEVELS.STANDARD) {
const isScreenSharingFilmstripParticipantFullResolution
= newMaxRecvVideoQualityForScreenSharingFilmstrip > VIDEO_QUALITY_LEVELS.STANDARD;
if (activeParticipantsCount > maxFullResolutionParticipants
- (isScreenSharingFilmstripParticipantFullResolution ? 1 : 0)) {
newMaxRecvVideoQualityForStageFilmstrip = VIDEO_QUALITY_LEVELS.STANDARD;
newMaxRecvVideoQualityForVerticalFilmstrip
= Math.min(VIDEO_QUALITY_LEVELS.STANDARD, newMaxRecvVideoQualityForVerticalFilmstrip);
} else if (newMaxRecvVideoQualityForVerticalFilmstrip > VIDEO_QUALITY_LEVELS.STANDARD
&& participantCount > maxFullResolutionParticipants - activeParticipantsCount) {
newMaxRecvVideoQualityForVerticalFilmstrip = VIDEO_QUALITY_LEVELS.STANDARD;
}
} else if (newMaxRecvVideoQualityForVerticalFilmstrip > VIDEO_QUALITY_LEVELS.STANDARD
&& participantCount > maxFullResolutionParticipants
- (newMaxRecvVideoQualityForLargeVideo > VIDEO_QUALITY_LEVELS.STANDARD ? 1 : 0)) {
newMaxRecvVideoQualityForVerticalFilmstrip = VIDEO_QUALITY_LEVELS.STANDARD;
}
}
}
if (maxReceiverVideoQualityForStageFilmstrip !== newMaxRecvVideoQualityForStageFilmstrip) {
maxVideoQualityChanged = true;
dispatch(setMaxReceiverVideoQualityForStageFilmstrip(newMaxRecvVideoQualityForStageFilmstrip));
}
if (maxReceiverVideoQualityForVerticalFilmstrip !== newMaxRecvVideoQualityForVerticalFilmstrip) {
maxVideoQualityChanged = true;
dispatch(setMaxReceiverVideoQualityForVerticalFilmstrip(newMaxRecvVideoQualityForVerticalFilmstrip));
}
if (maxReceiverVideoQualityForLargeVideo !== newMaxRecvVideoQualityForLargeVideo) {
maxVideoQualityChanged = true;
dispatch(setMaxReceiverVideoQualityForLargeVideo(newMaxRecvVideoQualityForLargeVideo));
}
if (maxReceiverVideoQualityForScreenSharingFilmstrip !== newMaxRecvVideoQualityForScreenSharingFilmstrip) {
maxVideoQualityChanged = true;
dispatch(
setMaxReceiverVideoQualityForScreenSharingFilmstrip(
newMaxRecvVideoQualityForScreenSharingFilmstrip));
}
}
if (!maxVideoQualityChanged && Boolean(displayTileView) !== Boolean(previousState.displayTileView)) {
_updateReceiverVideoConstraints(store);
}
}, {
deepEquals: true
});
/**
* Returns the source names associated with the given participants list.
*
* @param {Array<string>} participantList - The list of participants.
* @param {Object} state - The redux state.
* @returns {Array<string>}
*/
function _getSourceNames(participantList: Array<string>, state: IReduxState): Array<string> {
const { remoteScreenShares } = state['features/video-layout'];
const tracks = state['features/base/tracks'];
const sourceNamesList: string[] = [];
participantList.forEach(participantId => {
if (getSsrcRewritingFeatureFlag(state)) {
const sourceNames: string[]
= getSourceNamesByMediaTypeAndParticipant(state, participantId, MEDIA_TYPE.VIDEO);
sourceNames.length && sourceNamesList.push(...sourceNames);
} else {
let sourceName: string;
if (remoteScreenShares.includes(participantId)) {
sourceName = participantId;
} else {
sourceName = getTrackSourceNameByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantId);
}
if (sourceName) {
sourceNamesList.push(sourceName);
}
}
});
return sourceNamesList;
}
/**
* Helper function for updating the preferred sender video constraint, based on the user preference.
*
* @param {number} preferred - The user preferred max frame height.
* @returns {void}
*/
function _setSenderVideoConstraint(preferred: number, { getState }: IStore) {
const state = getState();
const { conference } = state['features/base/conference'];
if (!conference) {
return;
}
logger.info(`Setting sender resolution to ${preferred}`);
conference.setSenderVideoConstraint(preferred)
.catch((error: any) => {
_handleParticipantError(error);
reportError(error, `Changing sender resolution to ${preferred} failed.`);
});
}
/**
* Private helper to calculate the receiver video constraints and set them on the bridge channel.
*
* @param {*} store - The redux store.
* @returns {void}
*/
function _updateReceiverVideoConstraints({ getState }: IStore) {
const state = getState();
const { conference } = state['features/base/conference'];
if (!conference) {
return;
}
const { lastN } = state['features/base/lastn'];
const {
maxReceiverVideoQualityForTileView,
maxReceiverVideoQualityForStageFilmstrip,
maxReceiverVideoQualityForVerticalFilmstrip,
maxReceiverVideoQualityForLargeVideo,
maxReceiverVideoQualityForScreenSharingFilmstrip,
preferredVideoQuality
} = state['features/video-quality'];
const { participantId: largeVideoParticipantId = '' } = state['features/large-video'];
const maxFrameHeightForTileView = Math.min(maxReceiverVideoQualityForTileView, preferredVideoQuality);
const maxFrameHeightForStageFilmstrip = Math.min(maxReceiverVideoQualityForStageFilmstrip, preferredVideoQuality);
const maxFrameHeightForVerticalFilmstrip
= Math.min(maxReceiverVideoQualityForVerticalFilmstrip, preferredVideoQuality);
const maxFrameHeightForLargeVideo
= Math.min(maxReceiverVideoQualityForLargeVideo, preferredVideoQuality);
const maxFrameHeightForScreenSharingFilmstrip
= Math.min(maxReceiverVideoQualityForScreenSharingFilmstrip, preferredVideoQuality);
const { remoteScreenShares } = state['features/video-layout'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
const tracks = state['features/base/tracks'];
const localParticipantId = getLocalParticipant(state)?.id;
const activeParticipantsIds = getActiveParticipantsIds(state);
const screenshareFilmstripParticipantId = isTopPanelEnabled(state) && getScreenshareFilmstripParticipantId(state);
const receiverConstraints: any = {
constraints: {},
defaultConstraints: { 'maxHeight': VIDEO_QUALITY_LEVELS.NONE },
lastN
};
let activeParticipantsSources: string[] = [];
let visibleRemoteTrackSourceNames: string[] = [];
let largeVideoSourceName: string | undefined;
receiverConstraints.onStageSources = [];
receiverConstraints.selectedSources = [];
if (visibleRemoteParticipants?.size) {
visibleRemoteTrackSourceNames = _getSourceNames(Array.from(visibleRemoteParticipants), state);
}
if (activeParticipantsIds?.length > 0) {
activeParticipantsSources = _getSourceNames(activeParticipantsIds, state);
}
if (localParticipantId !== largeVideoParticipantId) {
if (remoteScreenShares.includes(largeVideoParticipantId)) {
largeVideoSourceName = largeVideoParticipantId;
} else {
largeVideoSourceName = getSsrcRewritingFeatureFlag(state)
? getSourceNamesByVideoTypeAndParticipant(state, largeVideoParticipantId, VIDEO_TYPE.CAMERA)[0]
: getTrackSourceNameByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, largeVideoParticipantId);
}
}
// Tile view.
if (shouldDisplayTileView(state)) {
if (!visibleRemoteTrackSourceNames?.length) {
return;
}
visibleRemoteTrackSourceNames.forEach(sourceName => {
receiverConstraints.constraints[sourceName] = { 'maxHeight': maxFrameHeightForTileView };
});
// Prioritize screenshare in tile view.
if (remoteScreenShares?.length) {
receiverConstraints.selectedSources = remoteScreenShares;
}
// Stage view.
} else {
if (!visibleRemoteTrackSourceNames?.length && !largeVideoSourceName && !activeParticipantsSources?.length) {
return;
}
if (visibleRemoteTrackSourceNames?.length) {
visibleRemoteTrackSourceNames.forEach(sourceName => {
receiverConstraints.constraints[sourceName] = { 'maxHeight': maxFrameHeightForVerticalFilmstrip };
});
}
if (getCurrentLayout(state) === LAYOUTS.STAGE_FILMSTRIP_VIEW && activeParticipantsSources.length > 0) {
const selectedSources: string[] = [];
const onStageSources: string[] = [];
// If more than one video source is pinned to the stage filmstrip, they need to be added to the
// 'selectedSources' so that the bridge can allocate bandwidth for all the sources as opposed to doing
// greedy allocation for the sources (which happens when they are added to 'onStageSources').
if (activeParticipantsSources.length > 1) {
selectedSources.push(...activeParticipantsSources);
} else {
onStageSources.push(activeParticipantsSources[0]);
}
activeParticipantsSources.forEach(sourceName => {
const isScreenSharing = remoteScreenShares.includes(sourceName);
const quality
= isScreenSharing && preferredVideoQuality >= MAX_VIDEO_QUALITY
? VIDEO_QUALITY_UNLIMITED : maxFrameHeightForStageFilmstrip;
receiverConstraints.constraints[sourceName] = { 'maxHeight': quality };
});
if (screenshareFilmstripParticipantId) {
onStageSources.push(screenshareFilmstripParticipantId);
receiverConstraints.constraints[screenshareFilmstripParticipantId]
= {
'maxHeight':
preferredVideoQuality >= MAX_VIDEO_QUALITY
? VIDEO_QUALITY_UNLIMITED : maxFrameHeightForScreenSharingFilmstrip
};
}
receiverConstraints.onStageSources = onStageSources;
receiverConstraints.selectedSources = selectedSources;
} else if (largeVideoSourceName) {
let quality = VIDEO_QUALITY_UNLIMITED;
if (preferredVideoQuality < MAX_VIDEO_QUALITY
|| !remoteScreenShares.find(id => id === largeVideoParticipantId)) {
quality = maxFrameHeightForLargeVideo;
}
receiverConstraints.constraints[largeVideoSourceName] = { 'maxHeight': quality };
receiverConstraints.onStageSources = [ largeVideoSourceName ];
}
}
try {
conference.setReceiverConstraints(receiverConstraints);
} catch (error: any) {
_handleParticipantError(error);
reportError(error, `Failed to set receiver video constraints ${JSON.stringify(receiverConstraints)}`);
}
}