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,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
}
});