bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx
prlanzarin d350afd194 fix(screenshare): check packet flow to detect unhealthy streams earlier
Screen streams were only deemed unhealthy when the transport's ICE state
transitioned to failed. That was as good as nothing because the stream would
stay frozen with no visual UI feedback until it reconnected. Bad UX.

This commit addresses that issue via two changes:
  - A stream is deemed *potentially* unhealthy now if the transport's
    state becomes disconnected
  - If a stream is deemed potentially unhealthy, a monitor probe is
    started to check whether there is media/packet flow (every 500ms).
    If there's no packet flow, the stream is flagged is factually unhealthy and
    UI feedback about that is rendered.

It's still not as good as it could be - relying on disconnected still
leaves a couple of seconds of silence to be dealt with. For that to be
addressed the prober would have to run nonstop, but that's for later.
2022-05-09 01:59:32 +00:00

528 lines
15 KiB
JavaScript
Executable File

import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import _ from 'lodash';
import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container';
import SwitchButtonContainer from './switch-button/container';
import Styled from './styles';
import VolumeSlider from '../external-video-player/volume-slider/component';
import AutoplayOverlay from '../media/autoplay-overlay/component';
import logger from '/imports/startup/client/logger';
import playAndRetry from '/imports/utils/mediaElementPlayRetry';
import { notify } from '/imports/ui/services/notification';
import {
SCREENSHARE_MEDIA_ELEMENT_NAME,
isMediaFlowing,
screenshareHasEnded,
screenshareHasStarted,
getMediaElement,
attachLocalPreviewStream,
setVolume,
getVolume,
getStats,
} from '/imports/ui/components/screenshare/service';
import {
isStreamStateHealthy,
subscribeToStreamStateChange,
unsubscribeFromStreamStateChange,
} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
import { ACTIONS } from '/imports/ui/components/layout/enums';
import Settings from '/imports/ui/services/settings';
import deviceInfo from '/imports/utils/deviceInfo';
const intlMessages = defineMessages({
screenShareLabel: {
id: 'app.screenshare.screenShareLabel',
description: 'screen share area element label',
},
presenterLoadingLabel: {
id: 'app.screenshare.presenterLoadingLabel',
},
viewerLoadingLabel: {
id: 'app.screenshare.viewerLoadingLabel',
},
presenterSharingLabel: {
id: 'app.screenshare.presenterSharingLabel',
},
autoplayBlockedDesc: {
id: 'app.media.screenshare.autoplayBlockedDesc',
},
autoplayAllowLabel: {
id: 'app.media.screenshare.autoplayAllowLabel',
},
screenshareStarted: {
id: 'app.media.screenshare.start',
description: 'toast to show when a screenshare has started',
},
screenshareEnded: {
id: 'app.media.screenshare.end',
description: 'toast to show when a screenshare has ended',
},
screenshareEndedDueToDataSaving: {
id: 'app.media.screenshare.endDueToDataSaving',
description: 'toast to show when a screenshare has ended by changing data savings option',
},
});
const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
const MOBILE_HOVER_TIMEOUT = 5000;
const MEDIA_FLOW_PROBE_INTERVAL = 500;
class ScreenshareComponent extends React.Component {
static renderScreenshareContainerInside(mainText) {
return (
<Styled.ScreenshareContainerInside>
<Styled.MainText>{mainText}</Styled.MainText>
</Styled.ScreenshareContainerInside>
);
}
constructor() {
super();
this.state = {
loaded: false,
autoplayBlocked: false,
mediaFlowing: false,
switched: false,
// Volume control hover toolbar
showHoverToolBar: false,
};
this.onLoadedData = this.onLoadedData.bind(this);
this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
this.failedMediaElements = [];
this.onStreamStateChange = this.onStreamStateChange.bind(this);
this.onSwitched = this.onSwitched.bind(this);
this.handleOnVolumeChanged = this.handleOnVolumeChanged.bind(this);
this.handleOnMuted = this.handleOnMuted.bind(this);
this.volume = getVolume();
this.mobileHoverSetTimeout = null;
this.mediaFlowMonitor = null;
}
componentDidMount() {
const {
isLayoutSwapped,
layoutContextDispatch,
intl,
isPresenter,
} = this.props;
screenshareHasStarted(isPresenter);
// Autoplay failure handling
window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed);
// Stream health state tracker to propagate UI changes on reconnections
subscribeToStreamStateChange('screenshare', this.onStreamStateChange);
// Attaches the local stream if it exists to serve as the local presenter preview
attachLocalPreviewStream(getMediaElement());
notify(intl.formatMessage(intlMessages.screenshareStarted), 'info', 'desktop');
layoutContextDispatch({
type: ACTIONS.SET_HAS_SCREEN_SHARE,
value: true,
});
if (isLayoutSwapped) {
layoutContextDispatch({
type: ACTIONS.SET_PRESENTATION_IS_OPEN,
value: true,
});
}
}
componentDidUpdate(prevProps) {
const { isPresenter } = this.props;
if (prevProps.isPresenter && !isPresenter) {
screenshareHasEnded();
}
}
componentWillUnmount() {
const {
intl,
fullscreenContext,
layoutContextDispatch,
toggleSwapLayout,
} = this.props;
screenshareHasEnded();
window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
unsubscribeFromStreamStateChange('screenshare', this.onStreamStateChange);
if (Settings.dataSaving.viewScreenshare) {
notify(intl.formatMessage(intlMessages.screenshareEnded), 'info', 'desktop');
} else {
notify(intl.formatMessage(intlMessages.screenshareEndedDueToDataSaving), 'info', 'desktop');
}
layoutContextDispatch({
type: ACTIONS.SET_HAS_SCREEN_SHARE,
value: false,
});
if (fullscreenContext) {
layoutContextDispatch({
type: ACTIONS.SET_FULLSCREEN_ELEMENT,
value: {
element: '',
group: '',
},
});
}
this.clearMediaFlowingMonitor();
}
clearMediaFlowingMonitor() {
if (this.mediaFlowMonitor) {
Meteor.clearInterval(this.mediaFlowMonitor);
this.mediaFlowMonitor = null;
}
}
handleAllowAutoplay() {
const { autoplayBlocked } = this.state;
logger.info({
logCode: 'screenshare_autoplay_allowed',
}, 'Screenshare media autoplay allowed by the user');
window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
while (this.failedMediaElements.length) {
const mediaElement = this.failedMediaElements.shift();
if (mediaElement) {
const played = playAndRetry(mediaElement);
if (!played) {
logger.error({
logCode: 'screenshare_autoplay_handling_failed',
}, 'Screenshare autoplay handling failed to play media');
} else {
logger.info({
logCode: 'screenshare_viewer_media_play_success',
}, 'Screenshare viewer media played successfully');
}
}
}
if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); }
}
handlePlayElementFailed(e) {
const { mediaElement } = e.detail;
const { autoplayBlocked } = this.state;
e.stopPropagation();
this.failedMediaElements.push(mediaElement);
if (!autoplayBlocked) {
logger.info({
logCode: 'screenshare_autoplay_prompt',
}, 'Prompting user for action to play screenshare media');
this.setState({ autoplayBlocked: true });
}
}
async monitorMediaFlow() {
let previousStats = await getStats();
this.mediaFlowMonitor = Meteor.setInterval(async () => {
const { mediaFlowing: prevMediaFlowing } = this.state;
let mediaFlowing;
const currentStats = await getStats();
try {
mediaFlowing = isMediaFlowing(previousStats, currentStats);
} catch (_error) {
// Stats processing failed for whatever reason - maintain previous state
mediaFlowing = prevMediaFlowing;
}
previousStats = currentStats;
if (prevMediaFlowing !== mediaFlowing) this.setState({ mediaFlowing });
}, MEDIA_FLOW_PROBE_INTERVAL);
}
onLoadedData() {
this.setState({ loaded: true });
}
onSwitched() {
this.setState((prevState) => ({ switched: !prevState.switched }));
}
handleOnVolumeChanged(volume) {
this.volume = volume;
setVolume(volume);
}
handleOnMuted(muted) {
if (muted) {
setVolume(0);
} else {
setVolume(this.volume);
}
}
onStreamStateChange(event) {
const { streamState } = event.detail;
const { mediaFlowing } = this.state;
const isStreamHealthy = isStreamStateHealthy(streamState);
event.stopPropagation();
if (isStreamHealthy) {
this.clearMediaFlowingMonitor();
// Current state is media not flowing - stream is now healthy so flip it
if (!mediaFlowing) this.setState({ mediaFlowing: isStreamHealthy });
} else {
if (this.mediaFlowMonitor == null) this.monitorMediaFlow();
}
}
renderFullscreenButton() {
const { intl, fullscreenElementId, fullscreenContext } = this.props;
if (!ALLOW_FULLSCREEN) return null;
return (
<FullscreenButtonContainer
key={_.uniqueId('fullscreenButton-')}
elementName={intl.formatMessage(intlMessages.screenShareLabel)}
fullscreenRef={this.screenshareContainer}
elementId={fullscreenElementId}
isFullscreen={fullscreenContext}
dark
/>
);
}
renderAutoplayOverlay() {
const { intl } = this.props;
return (
<AutoplayOverlay
key={_.uniqueId('screenshareAutoplayOverlay')}
autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)}
autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)}
handleAllowAutoplay={this.handleAllowAutoplay}
/>
);
}
renderSwitchButton() {
const { switched } = this.state;
return (
<SwitchButtonContainer
handleSwitch={this.onSwitched}
switched={switched}
dark
/>
);
}
renderMobileVolumeControlOverlay () {
return (
<Styled.MobileControlsOverlay
key="mobile-overlay-screenshare"
ref={(ref) => { this.overlay = ref; }}
onTouchStart={() => {
clearTimeout(this.mobileHoverSetTimeout);
this.setState({ showHoverToolBar: true });
}}
onTouchEnd={() => {
this.mobileHoverSetTimeout = setTimeout(
() => this.setState({ showHoverToolBar: false }),
MOBILE_HOVER_TIMEOUT,
);
}}
/>
);
}
renderVolumeSlider() {
const { showHoverToolBar } = this.state;
let toolbarStyle = 'hoverToolbar';
if (deviceInfo.isMobile && !showHoverToolBar) {
toolbarStyle = 'dontShowMobileHoverToolbar';
}
if (deviceInfo.isMobile && showHoverToolBar) {
toolbarStyle = 'showMobileHoverToolbar';
}
return [(
<Styled.HoverToolbar
toolbarStyle={toolbarStyle}
key='hover-toolbar-screenshare'>
<VolumeSlider
volume={getVolume()}
muted={getVolume() === 0}
onVolumeChanged={this.handleOnVolumeChanged}
onMuted={this.handleOnMuted}
/>
</Styled.HoverToolbar>
),
(deviceInfo.isMobile) && this.renderMobileVolumeControlOverlay(),
];
}
renderVideo(switched) {
const { isGloballyBroadcasting } = this.props;
const { mediaFlowing } = this.state;
return (
<Styled.ScreenshareVideo
id={SCREENSHARE_MEDIA_ELEMENT_NAME}
key={SCREENSHARE_MEDIA_ELEMENT_NAME}
unhealthyStream={!isGloballyBroadcasting || !mediaFlowing}
style={switched
? { maxHeight: '100%', width: '100%', height: '100%' }
: { maxHeight: '25%', width: '25%', height: '25%' }}
playsInline
onLoadedData={this.onLoadedData}
ref={(ref) => {
this.videoTag = ref;
}}
muted
/>
);
}
renderScreensharePresenter() {
const { switched } = this.state;
const { isGloballyBroadcasting, intl } = this.props;
return (
<Styled.ScreenshareContainer
switched={switched}
key="screenshareContainer"
ref={(ref) => { this.screenshareContainer = ref; }}
>
{isGloballyBroadcasting && this.renderSwitchButton()}
{this.renderVideo(switched)}
{
isGloballyBroadcasting
? (
<div data-test="isSharingScreen">
{!switched
&& ScreenshareComponent.renderScreenshareContainerInside(
intl.formatMessage(intlMessages.presenterSharingLabel),
)}
</div>
)
: ScreenshareComponent.renderScreenshareContainerInside(
intl.formatMessage(intlMessages.presenterLoadingLabel),
)
}
</Styled.ScreenshareContainer>
);
}
renderScreenshareDefault() {
const { intl, enableVolumeControl } = this.props;
const { loaded } = this.state;
return (
<Styled.ScreenshareContainer
switched
key="screenshareContainer"
ref={(ref) => {
this.screenshareContainer = ref;
}}
>
{loaded && this.renderFullscreenButton()}
{this.renderVideo(true)}
{loaded && enableVolumeControl && this.renderVolumeSlider() }
<Styled.ScreenshareContainerDefault>
{
!loaded
? ScreenshareComponent.renderScreenshareContainerInside(
intl.formatMessage(intlMessages.viewerLoadingLabel),
)
: null
}
</Styled.ScreenshareContainerDefault>
</Styled.ScreenshareContainer>
);
}
render() {
const { loaded, autoplayBlocked, mediaFlowing} = this.state;
const {
isPresenter,
isGloballyBroadcasting,
top,
left,
right,
width,
height,
zIndex,
fullscreenContext,
} = this.props;
// Conditions to render the (re)connecting dots and the unhealthy stream
// grayscale:
// 1 - The local media tag has not received any stream data yet
// 2 - The user is a presenter and the stream wasn't globally broadcasted yet
// 3 - The media was loaded, the stream was globally broadcasted BUT the stream
// state transitioned to an unhealthy stream. tl;dr: screen sharing reconnection
const shouldRenderConnectingState = !loaded
|| (isPresenter && !isGloballyBroadcasting)
|| (!mediaFlowing && loaded && isGloballyBroadcasting);
const display = (width > 0 && height > 0) ? 'inherit' : 'none';
const { animations } = Settings.application;
return (
<div
style={
{
position: 'absolute',
display,
top,
left,
right,
height,
width,
zIndex: fullscreenContext ? zIndex : undefined,
backgroundColor: '#06172A',
}
}
>
{(shouldRenderConnectingState)
&& (
<Styled.SpinnerWrapper
key={_.uniqueId('screenshareArea-')}
data-test="screenshareConnecting"
>
<Styled.Spinner animations={animations}>
<Styled.Bounce1 animations={animations} />
<Styled.Bounce2 animations={animations} />
<div />
</Styled.Spinner>
</Styled.SpinnerWrapper>
)}
{autoplayBlocked ? this.renderAutoplayOverlay() : null}
{isPresenter ? this.renderScreensharePresenter() : this.renderScreenshareDefault()}
</div>
);
}
}
export default injectIntl(ScreenshareComponent);
ScreenshareComponent.propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
isPresenter: PropTypes.bool.isRequired,
enableVolumeControl: PropTypes.bool.isRequired,
};