This commit is contained in:
72
react/features/video-quality/actionTypes.ts
Normal file
72
react/features/video-quality/actionTypes.ts
Normal 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';
|
||||
135
react/features/video-quality/actions.ts
Normal file
135
react/features/video-quality/actions.ts
Normal 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)));
|
||||
};
|
||||
}
|
||||
168
react/features/video-quality/components/Slider.web.tsx
Normal file
168
react/features/video-quality/components/Slider.web.tsx
Normal 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;
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
20
react/features/video-quality/components/styles.ts
Normal file
20
react/features/video-quality/components/styles.ts
Normal 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
|
||||
}
|
||||
});
|
||||
55
react/features/video-quality/constants.ts
Normal file
55
react/features/video-quality/constants.ts
Normal 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
|
||||
};
|
||||
65
react/features/video-quality/functions.ts
Normal file
65
react/features/video-quality/functions.ts
Normal 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;
|
||||
}
|
||||
3
react/features/video-quality/logger.ts
Normal file
3
react/features/video-quality/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/video-quality');
|
||||
45
react/features/video-quality/middleware.ts
Normal file
45
react/features/video-quality/middleware.ts
Normal 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;
|
||||
});
|
||||
130
react/features/video-quality/reducer.ts
Normal file
130
react/features/video-quality/reducer.ts
Normal 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;
|
||||
}
|
||||
31
react/features/video-quality/selector.ts
Normal file
31
react/features/video-quality/selector.ts
Normal 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;
|
||||
}
|
||||
517
react/features/video-quality/subscriber.ts
Normal file
517
react/features/video-quality/subscriber.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user