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