diff --git a/bigbluebutton-html5/imports/ui/components/media/component.jsx b/bigbluebutton-html5/imports/ui/components/media/component.jsx index aefa50672e43c8804535e5e4e3cb5c367f414427..e3dd38baf921d3234362dbc11323c7ae1229e6e2 100644 --- a/bigbluebutton-html5/imports/ui/components/media/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/media/component.jsx @@ -45,6 +45,7 @@ export default class Media extends Component { audioModalIsOpen, usersVideo, layoutContextState, + isMeteorConnected, } = this.props; const { webcamsPlacement: placement } = layoutContextState; @@ -67,7 +68,7 @@ export default class Media extends Component { [styles.containerH]: webcamsPlacement === 'left' || webcamsPlacement === 'right', }); const { viewParticipantsWebcams } = Settings.dataSaving; - const showVideo = usersVideo.length > 0 && viewParticipantsWebcams; + const showVideo = usersVideo.length > 0 && viewParticipantsWebcams && isMeteorConnected; const fullHeight = !showVideo || (webcamsPlacement === 'floating'); return ( diff --git a/bigbluebutton-html5/imports/ui/components/media/container.jsx b/bigbluebutton-html5/imports/ui/components/media/container.jsx index 7a6b1b630f4bfcb6c13532ed85450a069f2117dc..29988871900f42376062b5849b3a97802ac77857 100755 --- a/bigbluebutton-html5/imports/ui/components/media/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/media/container.jsx @@ -115,6 +115,7 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => { const data = { children: <DefaultContent {...{ autoSwapLayout, hidePresentation }} />, audioModalIsOpen: Session.get('audioModalIsOpen'), + isMeteorConnected: Meteor.status().connected, }; if (MediaService.shouldShowWhiteboard() && !hidePresentation) { diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 02c4533a6f155303ce432775aa94b0a2d95f7ea4..c2b6fb475db134c3d19ed5eda99780a3cc2c31f4 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -11,6 +11,7 @@ import { } from '/imports/utils/fetchStunTurnServers'; import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc'; import logger from '/imports/startup/client/logger'; +import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; // Default values and default empty object to be backwards compat with 2.2. // FIXME Remove hardcoded defaults 2.3. @@ -107,7 +108,6 @@ class VideoProvider extends Component { { connectionTimeout: WS_CONN_TIMEOUT }, ); this.wsQueue = []; - this.restartTimeout = {}; this.restartTimer = {}; this.webRtcPeers = {}; @@ -115,10 +115,10 @@ class VideoProvider extends Component { this.videoTags = {}; this.createVideoTag = this.createVideoTag.bind(this); + this.destroyVideoTag = this.destroyVideoTag.bind(this); this.onWsOpen = this.onWsOpen.bind(this); this.onWsClose = this.onWsClose.bind(this); this.onWsMessage = this.onWsMessage.bind(this); - this.updateStreams = this.updateStreams.bind(this); this.debouncedConnectStreams = _.debounce( this.connectStreams, @@ -164,7 +164,7 @@ class VideoProvider extends Component { VideoService.exitVideo(); Object.keys(this.webRtcPeers).forEach((cameraId) => { - this.stopWebRTCPeer(cameraId); + this.stopWebRTCPeer(cameraId, false); }); // Close websocket connection to prevent multiple reconnects from happening @@ -204,7 +204,7 @@ class VideoProvider extends Component { } onWsClose() { - logger.debug({ + logger.info({ logCode: 'video_provider_onwsclose', }, 'Multiple video provider websocket connection closed.'); @@ -216,7 +216,7 @@ class VideoProvider extends Component { } onWsOpen() { - logger.debug({ + logger.info({ logCode: 'video_provider_onwsopen', }, 'Multiple video provider websocket connection opened.'); @@ -266,7 +266,7 @@ class VideoProvider extends Component { } disconnectStreams(streamsToDisconnect) { - streamsToDisconnect.forEach(cameraId => this.stopWebRTCPeer(cameraId)); + streamsToDisconnect.forEach(cameraId => this.stopWebRTCPeer(cameraId, false)); } updateStreams(streams, shouldDebounce = false) { @@ -300,10 +300,10 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_ws_send_error', extraInfo: { - sfuRequest: message, - error, + errorMessage: error.message || 'Unknown', + errorCode: error.code, }, - }, `WebSocket failed when sending request to SFU due to ${error.message}`); + }, 'Camera request failed to be sent to SFU'); } }); } else if (message.id !== 'stop') { @@ -328,13 +328,10 @@ class VideoProvider extends Component { const { cameraId, role } = message; const peer = this.webRtcPeers[cameraId]; - logger.info({ + logger.debug({ logCode: 'video_provider_start_response_success', - extraInfo: { - cameraId, - sfuResponse: message, - }, - }, `Camera start request was accepted by SFU, processing response for ${cameraId}`); + extraInfo: { cameraId, role }, + }, `Camera start request accepted by SFU. Role: ${role}`); if (peer) { peer.processAnswer(message.sdpAnswer, (error) => { @@ -343,9 +340,11 @@ class VideoProvider extends Component { logCode: 'video_provider_peerconnection_processanswer_error', extraInfo: { cameraId, - error, + role, + errorMessage: error.message, + errorCode: error.code, }, - }, `Processing SDP answer from SFU for ${cameraId} failed due to ${error.message}`); + }, 'Camera answer processing failed'); return; } @@ -357,7 +356,8 @@ class VideoProvider extends Component { } else { logger.warn({ logCode: 'video_provider_startresponse_no_peer', - }, `SFU start response for ${cameraId} arrived after the peer was discarded, ignore it.`); + extraInfo: { cameraId, role }, + }, 'No peer on SFU camera start response handler'); } } @@ -365,13 +365,6 @@ class VideoProvider extends Component { const { cameraId, candidate } = message; const peer = this.webRtcPeers[cameraId]; - logger.debug({ - logCode: 'video_provider_ice_candidate_received', - extraInfo: { - candidate, - }, - }, `video-provider received candidate for ${cameraId}: ${JSON.stringify(candidate)}`); - if (peer) { if (peer.didSDPAnswered) { VideoService.addCandidateToPeer(peer, candidate, cameraId); @@ -389,7 +382,19 @@ class VideoProvider extends Component { } else { logger.warn({ logCode: 'video_provider_addicecandidate_no_peer', - }, `SFU ICE candidate for ${cameraId} arrived after the peer was discarded, ignore it.`); + extraInfo: { cameraId }, + }, 'Trailing camera ICE candidate, discarded'); + } + } + + clearRestartTimers (cameraId) { + if (this.restartTimeout[cameraId]) { + clearTimeout(this.restartTimeout[cameraId]); + delete this.restartTimeout[cameraId]; + } + + if (this.restartTimer[cameraId]) { + delete this.restartTimer[cameraId]; } } @@ -412,7 +417,9 @@ class VideoProvider extends Component { logger.info({ logCode: 'video_provider_stopping_webcam_sfu', - }, `Sending stop request to SFU. Camera: ${cameraId}, role ${role} and flag restarting ${restarting}`); + extraInfo: { role, cameraId, restarting }, + }, `Camera feed stop requested. Role ${role}, restarting ${restarting}`); + this.sendMessage({ id: 'stop', type: 'video', @@ -423,14 +430,7 @@ class VideoProvider extends Component { // Clear the shared camera media flow timeout and current reconnect period // when destroying it if the peer won't restart if (!restarting) { - if (this.restartTimeout[cameraId]) { - clearTimeout(this.restartTimeout[cameraId]); - delete this.restartTimeout[cameraId]; - } - - if (this.restartTimer[cameraId]) { - delete this.restartTimer[cameraId]; - } + this.clearRestartTimers(cameraId); } this.destroyWebRTCPeer(cameraId); @@ -438,10 +438,10 @@ class VideoProvider extends Component { destroyWebRTCPeer(cameraId) { const peer = this.webRtcPeers[cameraId]; + const isLocal = VideoService.isLocalStream(cameraId); + const role = VideoService.getRole(isLocal); + if (peer) { - logger.info({ - logCode: 'video_provider_destroywebrtcpeer', - }, `Disposing WebRTC peer ${cameraId}`); if (typeof peer.dispose === 'function') { peer.dispose(); } @@ -450,12 +450,14 @@ class VideoProvider extends Component { } else { logger.warn({ logCode: 'video_provider_destroywebrtcpeer_no_peer', - }, `Peer ${cameraId} was already disposed (glare), ignore it.`); + extraInfo: { cameraId, role }, + }, 'Trailing camera destroy request.'); } } async createWebRTCPeer(cameraId, isLocal) { let iceServers = []; + const role = VideoService.getRole(isLocal); // Check if the peer is already being processed if (this.webRtcPeers[cameraId]) { @@ -473,6 +475,8 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_no_valid_candidate_gum_failure', extraInfo: { + cameraId, + role, errorName: error.name, errorMessage: error.message, }, @@ -486,6 +490,8 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_fetchstunturninfo_error', extraInfo: { + cameraId, + role, errorCode: error.code, errorMessage: error.message, }, @@ -542,7 +548,7 @@ class VideoProvider extends Component { id: 'start', type: 'video', cameraId, - role: VideoService.getRole(isLocal), + role, sdpOffer: offerSdp, meetingId: this.info.meetingId, voiceBridge: this.info.voiceBridge, @@ -555,12 +561,14 @@ class VideoProvider extends Component { logger.info({ logCode: 'video_provider_sfu_request_start_camera', extraInfo: { - sfuRequest: message, + cameraId, cameraProfile: profileId, + role, }, - }, `Camera offer generated. Sending start request to SFU for ${cameraId}`); + }, `Camera offer generated. Role: ${role}`); this.sendMessage(message); + this.setReconnectionTimeout(cameraId, isLocal, false); return false; }); @@ -570,8 +578,9 @@ class VideoProvider extends Component { const peer = this.webRtcPeers[cameraId]; if (peer && peer.peerConnection) { const conn = peer.peerConnection; - conn.oniceconnectionstatechange = this - ._getOnIceConnectionStateChangeCallback(cameraId, isLocal); + conn.onconnectionstatechange = () => { + this._handleIceConnectionStateChange(cameraId, isLocal); + }; VideoService.monitor(conn); } } @@ -581,16 +590,12 @@ class VideoProvider extends Component { const { intl } = this.props; return () => { - // Peer that timed out is a sharer/publisher - if (isLocal) { - logger.error({ - logCode: 'video_provider_camera_share_timeout', - extraInfo: { cameraId }, - }, `Camera SHARER has not succeeded in ${CAMERA_SHARE_FAILED_WAIT_TIME} for ${cameraId}`); - - VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout)); - this.stopWebRTCPeer(cameraId); - } else { + const role = VideoService.getRole(isLocal); + if (!isLocal) { + // Peer that timed out is a subscriber/viewer + // Subscribers try to reconnect according to their timers if media could + // not reach the server. That's why we pass the restarting flag as true + // to the stop procedure as to not destroy the timers // Create new reconnect interval time const oldReconnectTimer = this.restartTimer[cameraId]; const newReconnectTimer = Math.min( @@ -604,80 +609,87 @@ class VideoProvider extends Component { delete this.restartTimeout[cameraId]; } - // Peer that timed out is a subscriber/viewer - // Subscribers try to reconnect according to their timers if media could - // not reach the server. That's why we pass the restarting flag as true - // to the stop procedure as to not destroy the timers logger.error({ logCode: 'video_provider_camera_view_timeout', extraInfo: { cameraId, + role, oldReconnectTimer, newReconnectTimer, }, - }, `Camera VIEWER has not succeeded in ${oldReconnectTimer} for ${cameraId}. Reconnecting.`); + }, 'Camera VIEWER failed. Reconnecting.'); - this.stopWebRTCPeer(cameraId, true); - this.createWebRTCPeer(cameraId, isLocal); + this.reconnect(cameraId, isLocal); + } else { + // Peer that timed out is a sharer/publisher, clean it up, stop. + logger.error({ + logCode: 'video_provider_camera_share_timeout', + extraInfo: { + cameraId, + role, + }, + }, 'Camera SHARER failed.'); + VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout)); + this.stopWebRTCPeer(cameraId, false); } }; } _onWebRTCError(error, cameraId, isLocal) { const { intl } = this.props; + const errorMessage = intlClientErrors[error.name] || intlSFUErrors[error]; - // 2001 means MEDIA_SERVER_OFFLINE. It's a server-wide error. - // We only display it to a sharer/publisher instance to avoid popping up - // redundant toasts. - // If the client only has viewer instances, the WS will close unexpectedly - // and an error will be shown there for them. - if (error === 2001 && !isLocal) { - return; - } + logger.error({ + logCode: 'video_provider_webrtc_peer_error', + extraInfo: { + cameraId, + role: VideoService.getRole(isLocal), + errorName: error.name, + errorMessage: error.message, + }, + }, 'Camera peer failed'); - const errorMessage = intlClientErrors[error.name] - || intlSFUErrors[error] || intlClientErrors.permissionError; // Only display WebRTC negotiation error toasts to sharers. The viewer streams // will try to autoreconnect silently, but the error will log nonetheless if (isLocal) { - VideoService.notify(intl.formatMessage(errorMessage)); + this.stopWebRTCPeer(cameraId, false); + if (errorMessage) VideoService.notify(intl.formatMessage(errorMessage)); } else { // If it's a viewer, set the reconnection timeout. There's a good chance // no local candidate was generated and it wasn't set. - this.setReconnectionTimeout(cameraId, isLocal); + const peer = this.webRtcPeers[cameraId]; + const isEstablishedConnection = peer && peer.started; + this.setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection); + // second argument means it will only try to reconnect if + // it's a viewer instance (see stopWebRTCPeer restarting argument) + this.stopWebRTCPeer(cameraId, true); } + } - // shareWebcam as the second argument means it will only try to reconnect if - // it's a viewer instance (see stopWebRTCPeer restarting argument) - this.stopWebRTCPeer(cameraId, !isLocal); - - logger.error({ - logCode: 'video_provider_webrtc_peer_error', - extraInfo: { - cameraId, - normalizedError: errorMessage, - error, - }, - }, `Camera peer creation failed for ${cameraId} due to ${error.message}`); + reconnect(cameraId, isLocal) { + this.stopWebRTCPeer(cameraId, true); + this.createWebRTCPeer(cameraId, isLocal); } - setReconnectionTimeout(cameraId, isLocal) { + setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection) { const peer = this.webRtcPeers[cameraId]; - const peerHasStarted = peer && peer.started === true; - const shouldSetReconnectionTimeout = !this.restartTimeout[cameraId] && !peerHasStarted; + const shouldSetReconnectionTimeout = !this.restartTimeout[cameraId] && !isEstablishedConnection; + + // This is an ongoing reconnection which succeeded in the first place but + // then failed mid call. Try to reconnect it right away. Clear the restart + // timers since we don't need them in this case. + if (isEstablishedConnection) { + this.clearRestartTimers(cameraId); + return this.reconnect(cameraId, isLocal); + } + // This is a reconnection timer for a peer that hasn't succeeded in the first + // place. Set reconnection timeouts with random intervals between them to try + // and reconnect without flooding the server if (shouldSetReconnectionTimeout) { const newReconnectTimer = this.restartTimer[cameraId] || CAMERA_SHARE_FAILED_WAIT_TIME; this.restartTimer[cameraId] = newReconnectTimer; - logger.info({ - logCode: 'video_provider_setup_reconnect', - extraInfo: { - cameraId, - reconnectTimer: newReconnectTimer, - }, - }, `Camera has a new reconnect timer of ${newReconnectTimer} ms for ${cameraId}`); - this.restartTimeout[cameraId] = setTimeout( this._getWebRTCStartTimeout(cameraId, isLocal), this.restartTimer[cameraId], @@ -689,16 +701,8 @@ class VideoProvider extends Component { return (candidate) => { const peer = this.webRtcPeers[cameraId]; const role = VideoService.getRole(isLocal); - // Setup a timeout only when the first candidate is generated and if the peer wasn't - // marked as started already (which is done on handlePlayStart after - // it was verified that media could circle through the server) - this.setReconnectionTimeout(cameraId, isLocal); if (peer && !peer.didSDPAnswered) { - logger.debug({ - logCode: 'video_provider_client_candidate', - extraInfo: { candidate }, - }, `video-provider client-side candidate queued for ${cameraId}`); this.outboundIceQueues[cameraId].push(candidate); return; } @@ -708,10 +712,6 @@ class VideoProvider extends Component { } sendIceCandidateToSFU(peer, role, candidate, cameraId) { - logger.debug({ - logCode: 'video_provider_client_candidate', - extraInfo: { candidate }, - }, `video-provider client-side candidate generated for ${cameraId}: ${JSON.stringify(candidate)}`); const message = { type: 'video', role, @@ -722,111 +722,111 @@ class VideoProvider extends Component { this.sendMessage(message); } - _getOnIceConnectionStateChangeCallback(cameraId, isLocal) { + _handleIceConnectionStateChange (cameraId, isLocal) { const { intl } = this.props; const peer = this.webRtcPeers[cameraId]; + const role = VideoService.getRole(isLocal); + if (peer && peer.peerConnection) { - const conn = peer.peerConnection; - const { iceConnectionState } = conn; + const pc = peer.peerConnection; + const connectionState = pc.connectionState; + notifyStreamStateChange(cameraId, connectionState); - return () => { - if (iceConnectionState === 'failed' || iceConnectionState === 'closed') { - // prevent the same error from being detected multiple times - conn.oniceconnectionstatechange = null; - logger.error({ - logCode: 'video_provider_ice_connection_failed_state', - extraInfo: { - cameraId, - iceConnectionState, - }, - }, `ICE connection state transitioned to ${iceConnectionState} for ${cameraId}`); + if (connectionState === 'failed' || connectionState === 'closed') { + const error = new Error('iceConnectionStateError'); + // prevent the same error from being detected multiple times + pc.onconnectionstatechange = null; - this.stopWebRTCPeer(cameraId); - VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError)); - } - }; - } - return () => { - logger.error({ - logCode: 'video_provider_ice_connection_failed_state', - extraInfo: { - cameraId, - iceConnectionState: undefined, - }, - }, `Missing peer at ICE connection state transition for ${cameraId}`); + logger.error({ + logCode: 'video_provider_ice_connection_failed_state', + extraInfo: { + cameraId, + connectionState, + role, + }, + }, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`); + if (isLocal) VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError)); - // isLocal as the second argument means it will only try to reconnect if - // it's a viewer instance (see stopWebRTCPeer restarting argument) - this.stopWebRTCPeer(cameraId, !isLocal); - VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError)); - }; + this._onWebRTCError( + error, + cameraId, + isLocal + ); + } + } else { + logger.error({ + logCode: 'video_provider_ice_connection_nopeer', + extraInfo: { cameraId, role }, + }, `No peer at ICE connection state handler. Camera: ${cameraId}. Role: ${role}`); + } } attachVideoStream(cameraId) { const video = this.videoTags[cameraId]; + if (video == null) { logger.warn({ logCode: 'video_provider_delay_attach_video_stream', extraInfo: { cameraId }, - }, `Will attach stream later because camera has not started yet for ${cameraId}`); + }, 'Delaying video stream attachment'); return; } - if (video.srcObject) { - delete this.videoTags[cameraId]; - return; // Skip if the stream is already attached - } - const isLocal = VideoService.isLocalStream(cameraId); const peer = this.webRtcPeers[cameraId]; + if (peer && peer.attached && video.srcObject) { + return; // Skip if the stream is already attached + } + const attachVideoStreamHelper = () => { const stream = isLocal ? peer.getLocalStream() : peer.getRemoteStream(); video.pause(); video.srcObject = stream; video.load(); - peer.attached = true; - delete this.videoTags[cameraId]; }; - - // If peer has started playing attach to tag, otherwise wait a while - if (peer) { - if (peer.started) { - attachVideoStreamHelper(); - } - - // So we can start it later when we get a playStart - // or if we need to do a restart timeout - peer.videoTag = video; - } + // Conditions to safely attach a stream to a video element in all browsers: + // 1 - Peer exists + // 2 - It hasn't been attached yet + // 3a - If the stream is a local one (webcam sharer), we can just attach it + // (no need to wait for server confirmation) + // 3b - If the stream is a remote one, the safest (*ahem* Safari) moment to + // do so is waiting for the server to confirm that media has flown out of it + // towards the remote end. + const isAbleToAttach = peer && !peer.attached && (peer.started || isLocal); + if (isAbleToAttach) attachVideoStreamHelper(); } createVideoTag(cameraId, video) { const peer = this.webRtcPeers[cameraId]; this.videoTags[cameraId] = video; - if (peer) { + if (peer && !peer.attached) { this.attachVideoStream(cameraId); } } + destroyVideoTag(cameraId) { + delete this.videoTags[cameraId] + } + handlePlayStop(message) { - const { cameraId } = message; + const { cameraId, role } = message; logger.info({ logCode: 'video_provider_handle_play_stop', extraInfo: { cameraId, - sfuRequest: message, + role, }, - }, `Received request from SFU to stop camera ${cameraId}`); + }, `Received request from SFU to stop camera. Role: ${role}`); this.stopWebRTCPeer(cameraId, false); } handlePlayStart(message) { - const { cameraId } = message; + const { cameraId, role } = message; const peer = this.webRtcPeers[cameraId]; if (peer) { @@ -834,16 +834,14 @@ class VideoProvider extends Component { logCode: 'video_provider_handle_play_start_flowing', extraInfo: { cameraId, - sfuResponse: message, + role, }, - }, `SFU says that media is flowing for camera ${cameraId}`); + }, `Camera media is flowing (server). Role: ${role}`); peer.started = true; // Clear camera shared timeout when camera succesfully starts - clearTimeout(this.restartTimeout[cameraId]); - delete this.restartTimeout[cameraId]; - delete this.restartTimer[cameraId]; + this.clearRestartTimers(cameraId); if (!peer.attached) { this.attachVideoStream(cameraId); @@ -851,8 +849,10 @@ class VideoProvider extends Component { VideoService.playStart(cameraId); } else { - logger.warn({ logCode: 'video_provider_playstart_no_peer' }, - `SFU playStart response for ${cameraId} arrived after the peer was discarded, ignore it.`); + logger.warn({ + logCode: 'video_provider_playstart_no_peer', + extraInfo: { cameraId, role }, + }, 'Trailing camera playStart response.'); } } @@ -860,15 +860,19 @@ class VideoProvider extends Component { const { intl } = this.props; const { code, reason, streamId } = message; const cameraId = streamId; + const isLocal = VideoService.isLocalStream(cameraId); + const role = VideoService.getRole(isLocal); + logger.error({ logCode: 'video_provider_handle_sfu_error', extraInfo: { - error: message, + errorCode: code, + errorReason: reason, cameraId, + role, }, - }, `SFU returned error for camera ${cameraId}. Code: ${code}, reason: ${reason}`); + }, `SFU returned an error. Code: ${code}, reason: ${reason}`); - const isLocal = VideoService.isLocalStream(cameraId); if (isLocal) { // The publisher instance received an error from the server. There's no reconnect, // stop it. @@ -885,7 +889,8 @@ class VideoProvider extends Component { return ( <VideoListContainer streams={streams} - onMount={this.createVideoTag} + onVideoItemMount={this.createVideoTag} + onVideoItemUnmount={this.destroyVideoTag} swapLayout={swapLayout} currentVideoPageIndex={currentVideoPageIndex} /> diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx index c4cef8f638b4eb9905b328678f97c828e8a52954..6d4a0d7f7567f608160877a42bd68798441f849f 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx @@ -14,7 +14,8 @@ import Button from '/imports/ui/components/button/component'; const propTypes = { streams: PropTypes.arrayOf(PropTypes.object).isRequired, - onMount: PropTypes.func.isRequired, + onVideoItemMount: PropTypes.func.isRequired, + onVideoItemUnmount: PropTypes.func.isRequired, webcamDraggableDispatch: PropTypes.func.isRequired, intl: PropTypes.objectOf(Object).isRequired, swapLayout: PropTypes.bool.isRequired, @@ -298,7 +299,8 @@ class VideoList extends Component { const { intl, streams, - onMount, + onVideoItemMount, + onVideoItemUnmount, swapLayout, } = this.props; const { focusedId } = this.state; @@ -340,10 +342,11 @@ class VideoList extends Component { name={name} mirrored={isMirrored} actions={actions} - onMount={(videoRef) => { + onVideoItemMount={(videoRef) => { this.handleCanvasResize(); - onMount(cameraId, videoRef); + onVideoItemMount(cameraId, videoRef); }} + onVideoItemUnmount={onVideoItemUnmount} swapLayout={swapLayout} /> </div> diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/container.jsx index 7bd122a866c956119436600fb84359e230a33c25..cf8d7ffc134eb59d38f6495f38e054158d69622b 100644 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/container.jsx @@ -10,7 +10,8 @@ const VideoListContainer = ({ children, ...props }) => { export default withTracker(props => ({ streams: props.streams, - onMount: props.onMount, + onVideoItemMount: props.onVideoItemMount, + onVideoItemUnmount: props.onVideoItemUnmount, swapLayout: props.swapLayout, numberOfPages: VideoService.getNumberOfPages(), currentVideoPageIndex: props.currentVideoPageIndex, diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/styles.scss b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/styles.scss index 9d61fd9736f14c7b2da588b9bf5aa89850b30b04..7f0d6e2961be80a1fde7569849be3ca4da7e3787 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/styles.scss @@ -1,5 +1,6 @@ @import "/imports/ui/stylesheets/variables/breakpoints"; @import "/imports/ui/stylesheets/variables/placeholders"; +@import "/imports/ui/components/media/styles"; .videoCanvas { --cam-dropdown-width: 70%; @@ -257,3 +258,13 @@ margin-right: 2px; } } + +.unhealthyStream { + filter: grayscale(50%) opacity(50%); +} + +.reconnecting { + @extend .connectingSpinner; + background-color: transparent; + color: var(--color-white); +} diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx index c83a8da8810303334ce57352dc43e241a27233d7..befcd59d06d9985dacd9cd8de778c54e068c2209 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx @@ -18,6 +18,11 @@ import FullscreenButtonContainer from '/imports/ui/components/fullscreen-button/ import { styles } from '../styles'; import { withDraggableConsumer } from '/imports/ui/components/media/webcam-draggable-overlay/context'; import VideoService from '../../service'; +import { + isStreamStateUnhealthy, + subscribeToStreamStateChange, + unsubscribeFromStreamStateChange, +} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen; @@ -29,16 +34,30 @@ class VideoListItem extends Component { this.state = { videoIsReady: false, isFullscreen: false, + isStreamHealthy: false, }; this.mirrorOwnWebcam = VideoService.mirrorOwnWebcam(props.userId); this.setVideoIsReady = this.setVideoIsReady.bind(this); this.onFullscreenChange = this.onFullscreenChange.bind(this); + this.onStreamStateChange = this.onStreamStateChange.bind(this); + } + + onStreamStateChange (e) { + const { streamState } = e.detail; + const { isStreamHealthy } = this.state; + + const newHealthState = !isStreamStateUnhealthy(streamState); + e.stopPropagation(); + + if (newHealthState !== isStreamHealthy) { + this.setState({ isStreamHealthy: newHealthState }); + } } componentDidMount() { - const { onMount, webcamDraggableDispatch } = this.props; + const { onVideoItemMount, webcamDraggableDispatch, cameraId } = this.props; webcamDraggableDispatch( { @@ -47,10 +66,10 @@ class VideoListItem extends Component { }, ); - onMount(this.videoTag); - + onVideoItemMount(this.videoTag); this.videoTag.addEventListener('loadeddata', this.setVideoIsReady); this.videoContainer.addEventListener('fullscreenchange', this.onFullscreenChange); + subscribeToStreamStateChange(cameraId, this.onStreamStateChange); } componentDidUpdate() { @@ -75,8 +94,12 @@ class VideoListItem extends Component { } componentWillUnmount() { + const { cameraId, onVideoItemUnmount } = this.props; + this.videoTag.removeEventListener('loadeddata', this.setVideoIsReady); this.videoContainer.removeEventListener('fullscreenchange', this.onFullscreenChange); + unsubscribeFromStreamStateChange(cameraId, this.onStreamStateChange); + onVideoItemUnmount(cameraId); } onFullscreenChange() { @@ -136,6 +159,7 @@ class VideoListItem extends Component { const { videoIsReady, isFullscreen, + isStreamHealthy, } = this.state; const { name, @@ -147,6 +171,7 @@ class VideoListItem extends Component { } = this.props; const availableActions = this.getAvailableActions(); const enableVideoMenu = Meteor.settings.public.kurento.enableVideoMenu || false; + const shouldRenderReconnect = !isStreamHealthy && videoIsReady; const result = browser(); const isFirefox = (result && result.name) ? result.name.includes('firefox') : false; @@ -162,7 +187,14 @@ class VideoListItem extends Component { <div data-test="webcamConnecting" className={styles.connecting}> <span className={styles.loadingText}>{name}</span> </div> + + } + + { + shouldRenderReconnect + && <div className={styles.reconnecting} /> } + <div className={styles.videoContainer} ref={(ref) => { this.videoContainer = ref; }} @@ -177,6 +209,7 @@ class VideoListItem extends Component { [styles.cursorGrabbing]: webcamDraggableState.dragging && !isFullscreen && !swapLayout, [styles.mirroredVideo]: (this.mirrorOwnWebcam && !mirrored) || (!this.mirrorOwnWebcam && mirrored), + [styles.unhealthyStream]: shouldRenderReconnect, })} ref={(ref) => { this.videoTag = ref; }} autoPlay