Merge pull request #16828 from prlanzarin/u27/fix/ss-cam-reconn

fix: re-connection improvements for cameras and screen sharing
This commit is contained in:
Anton Georgiev 2023-04-05 16:13:01 -04:00 committed by GitHub
commit e2dc7da98a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 397 additions and 202 deletions

View File

@ -6,6 +6,7 @@ import { setSharingScreen, screenShareEndAlert } from '/imports/ui/components/sc
import { SCREENSHARING_ERRORS } from './errors';
import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
import MediaStreamUtils from '/imports/utils/media-stream-utils';
import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
const SFU_CONFIG = Meteor.settings.public.kurento;
const SFU_URL = SFU_CONFIG.wsUrl;
@ -52,6 +53,7 @@ export default class KurentoScreenshareBridge {
this.reconnecting = false;
this.reconnectionTimeout;
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
this.startedOnce = false;
}
get gdmStream() {
@ -64,7 +66,7 @@ export default class KurentoScreenshareBridge {
_shouldReconnect() {
// Sender/presenter reconnect is *not* implemented yet
return this.broker.started && this.role === RECV_ROLE;
return this.reconnectionTimeout == null && this.role === RECV_ROLE;
}
/**
@ -147,9 +149,12 @@ export default class KurentoScreenshareBridge {
return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS;
}
scheduleReconnect(immediate = false) {
scheduleReconnect({
overrideTimeout,
} = { }) {
if (this.reconnectionTimeout == null) {
const nextRestartInterval = immediate ? 0 : this.restartIntervalMs;
let nextRestartInterval = this.restartIntervalMs;
if (typeof overrideTimeout === 'number') nextRestartInterval = overrideTimeout;
this.reconnectionTimeout = setTimeout(
this.handleConnectionTimeoutExpiry.bind(this),
@ -198,6 +203,7 @@ export default class KurentoScreenshareBridge {
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio);
}
this.startedOnce = true;
this.clearReconnectionTimeout();
this.connectionAttempts = 0;
}
@ -209,21 +215,31 @@ export default class KurentoScreenshareBridge {
logger.error({
logCode: 'screenshare_broker_failure',
extraInfo: {
errorCode, errorMessage,
errorCode,
errorMessage,
role: this.broker.role,
started: this.broker.started,
reconnecting: this.reconnecting,
bridge: BRIDGE_NAME
bridge: BRIDGE_NAME,
},
}, `Screenshare broker failure: ${errorMessage}`);
notifyStreamStateChange('screenshare', 'failed');
// Screensharing was already successfully negotiated and error occurred during
// during call; schedule a reconnect
// If the session has not yet started, a reconnect should already be scheduled
if (this._shouldReconnect()) {
// this.broker.started => whether the reconnect should happen immediately.
// If this session had alredy been established, it should.
this.scheduleReconnect(this.broker.started);
// If this session previously established connection (N-sessions back)
// and it failed abruptly, then the timeout is overridden to a intermediate value
// (BASE_RECONNECTION_TIMEOUT)
let overrideTimeout;
if (this.broker?.started) {
overrideTimeout = 0;
} else if (this.startedOnce) {
overrideTimeout = BridgeService.BASE_RECONNECTION_TIMEOUT;
}
this.scheduleReconnect({ overrideTimeout });
}
return error;
@ -266,6 +282,7 @@ export default class KurentoScreenshareBridge {
logCode: 'screenshare_presenter_start_success',
}, 'Screenshare presenter started succesfully');
this.clearReconnectionTimeout();
this.startedOnce = true;
this.reconnecting = false;
this.connectionAttempts = 0;
}

View File

@ -16,6 +16,7 @@ const {
maxTimeout: MAX_MEDIA_TIMEOUT,
maxConnectionAttempts: MAX_CONN_ATTEMPTS,
timeoutIncreaseFactor: TIMEOUT_INCREASE_FACTOR,
baseReconnectionTimeout: BASE_RECONNECTION_TIMEOUT,
} = MEDIA_TIMEOUTS;
const HAS_DISPLAY_MEDIA = (typeof navigator.getDisplayMedia === 'function'
@ -111,7 +112,7 @@ const getMediaServerAdapter = () => {
const getNextReconnectionInterval = (oldInterval) => {
return Math.min(
TIMEOUT_INCREASE_FACTOR * oldInterval,
(TIMEOUT_INCREASE_FACTOR * Math.max(oldInterval, BASE_RECONNECTION_TIMEOUT)),
MAX_MEDIA_TIMEOUT,
);
}
@ -157,6 +158,7 @@ export default {
screenshareLoadAndPlayMediaStream,
getMediaServerAdapter,
BASE_MEDIA_TIMEOUT,
BASE_RECONNECTION_TIMEOUT,
MAX_CONN_ATTEMPTS,
BASE_BITRATE,
};

View File

@ -125,7 +125,7 @@ const ScreenshareButton = ({
const handleFailure = (error) => {
const {
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
errorMessage,
errorMessage = error.message,
} = error;
const localizedError = getErrorLocale(errorCode);

View File

@ -242,9 +242,16 @@ class ScreenshareComponent extends React.Component {
try {
mediaFlowing = isMediaFlowing(previousStats, currentStats);
} catch (_error) {
} catch (error) {
// Stats processing failed for whatever reason - maintain previous state
mediaFlowing = prevMediaFlowing;
logger.warn({
logCode: 'screenshare_media_monitor_stats_failed',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Failed to collect screenshare stats, flow monitor');
}
previousStats = currentStats;
@ -323,9 +330,7 @@ class ScreenshareComponent extends React.Component {
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();
}
} else if (this.mediaFlowMonitor == null) this.monitorMediaFlow();
}
renderFullscreenButton() {

View File

@ -245,8 +245,8 @@ const getStats = async (statsTypes = DEFAULT_SCREENSHARE_STATS_TYPES) => {
// This method may throw errors
const isMediaFlowing = (previousStats, currentStats) => {
const bpsData = ConnectionStatusService.calculateBitsPerSecond(
currentStats.screenshareStats,
previousStats.screenshareStats,
currentStats?.screenshareStats,
previousStats?.screenshareStats,
);
const bpsDataAggr = Object.values(bpsData)
.reduce((sum, partialBpsData = 0) => sum + parseFloat(partialBpsData), 0);

View File

@ -25,7 +25,16 @@ import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
// Default values and default empty object to be backwards compat with 2.2.
// FIXME Remove hardcoded defaults 2.3.
const WS_CONN_TIMEOUT = Meteor.settings.public.kurento.wsConnectionTimeout || 4000;
const {
connectionTimeout: WS_CONN_TIMEOUT = 4000,
maxRetries: WS_MAX_RETRIES = 5,
debug: WS_DEBUG,
heartbeat: WS_HEARTBEAT_OPTS = {
interval: 15000,
delay: 3000,
reconnectOnFailure: true,
},
} = Meteor.settings.public.kurento.cameraWsOptions;
const { webcam: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
const {
@ -36,7 +45,6 @@ const {
enabled: CAMERA_QUALITY_THRESHOLDS_ENABLED = true,
privilegedStreams: CAMERA_QUALITY_THR_PRIVILEGED = true,
} = Meteor.settings.public.kurento.cameraQualityThresholds;
const PING_INTERVAL = 15000;
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
@ -114,6 +122,7 @@ const propTypes = {
swapLayout: PropTypes.bool.isRequired,
currentVideoPageIndex: PropTypes.number.isRequired,
totalNumberOfStreams: PropTypes.number.isRequired,
isMeteorConnected: PropTypes.bool.isRequired,
};
class VideoProvider extends Component {
@ -121,6 +130,23 @@ class VideoProvider extends Component {
VideoService.onBeforeUnload();
}
static isAbleToAttach(peer) {
// 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 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 te remote end (peer.started)
// 3b - If the stream is a local one (webcam sharer) and is started
// 4 - If the stream is local one, check if there area video tracks there are
// video tracks: attach it
if (peer == null || peer.attached) return false;
if (peer.started) return true;
return peer.isPublisher
&& peer.getLocalStream()
&& peer.getLocalStream().getVideoTracks().length > 0;
}
constructor(props) {
super(props);
@ -129,16 +155,9 @@ class VideoProvider extends Component {
socketOpen: false,
};
this._isMounted = false;
this.info = VideoService.getInfo();
// Set a valid bbb-webrtc-sfu application server socket in the settings
this.ws = new ReconnectingWebSocket(
VideoService.getAuthenticatedURL(),
[],
{ connectionTimeout: WS_CONN_TIMEOUT },
);
this.wsQueue = [];
// Signaling message queue arrays indexed by stream (== cameraId)
this.wsQueues = {};
this.restartTimeout = {};
this.restartTimer = {};
this.webRtcPeers = {};
@ -162,53 +181,120 @@ class VideoProvider extends Component {
componentDidMount() {
this._isMounted = true;
VideoService.updatePeerDictionaryReference(this.webRtcPeers);
this.ws.onopen = this.onWsOpen;
this.ws.onclose = this.onWsClose;
window.addEventListener('online', this.openWs);
window.addEventListener('offline', this.onWsClose);
this.ws.onmessage = this.onWsMessage;
this.ws = this.openWs();
window.addEventListener('beforeunload', VideoProvider.onBeforeUnload);
}
componentDidUpdate(prevProps) {
const { isUserLocked, streams, currentVideoPageIndex } = this.props;
const {
isUserLocked,
streams,
currentVideoPageIndex,
isMeteorConnected
} = this.props;
const { socketOpen } = this.state;
// Only debounce when page changes to avoid unecessary debouncing
const shouldDebounce = VideoService.isPaginationEnabled()
&& prevProps.currentVideoPageIndex !== currentVideoPageIndex;
this.updateStreams(streams, shouldDebounce);
if (isMeteorConnected && socketOpen) this.updateStreams(streams, shouldDebounce);
if (!prevProps.isUserLocked && isUserLocked) VideoService.lockUser();
// Signaling socket expired its retries and meteor is connected - create
// a new signaling socket instance from scratch
if (!socketOpen
&& isMeteorConnected
&& this.ws == null) {
this.ws = this.openWs();
}
}
componentWillUnmount() {
this._isMounted = false;
VideoService.updatePeerDictionaryReference({});
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
window.removeEventListener('online', this.openWs);
window.removeEventListener('offline', this.onWsClose);
window.removeEventListener('beforeunload', VideoProvider.onBeforeUnload);
VideoService.exitVideo();
Object.keys(this.webRtcPeers).forEach((stream) => {
this.stopWebRTCPeer(stream, false);
});
this.terminateWs();
}
// Close websocket connection to prevent multiple reconnects from happening
this.ws.close();
this._isMounted = false;
openWs() {
const ws = new ReconnectingWebSocket(
VideoService.getAuthenticatedURL(), [], {
connectionTimeout: WS_CONN_TIMEOUT,
debug: WS_DEBUG,
maxRetries: WS_MAX_RETRIES,
maxEnqueuedMessages: 0,
}
);
ws.onopen = this.onWsOpen;
ws.onclose = this.onWsClose;
ws.onmessage = this.onWsMessage;
return ws;
}
terminateWs() {
if (this.ws) {
this.clearWSHeartbeat();
this.ws.close();
this.ws = null;
}
}
_updateLastMsgTime() {
this.ws.isAlive = true;
this.ws.lastMsgTime = Date.now();
}
_getTimeSinceLastMsg() {
return Date.now() - this.ws.lastMsgTime;
}
setupWSHeartbeat() {
if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null || this.ws.wsHeartbeat) return;
this.ws.isAlive = true;
this.ws.wsHeartbeat = setInterval(() => {
if (this.ws.isAlive === false) {
logger.warn({
logCode: 'video_provider_ws_heartbeat_failed',
}, 'Video provider WS heartbeat failed.');
if (WS_HEARTBEAT_OPTS.reconnectOnFailure) this.ws.reconnect();
return;
}
if (this._getTimeSinceLastMsg() < (
WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
)) {
return;
}
this.ws.isAlive = false;
this.ping();
}, WS_HEARTBEAT_OPTS.interval);
this.ping();
}
clearWSHeartbeat() {
if (this.ws?.wsHeartbeat) {
clearInterval(this.ws.wsHeartbeat);
this.ws.wsHeartbeat = null;
}
}
onWsMessage(message) {
this._updateLastMsgTime();
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.id === 'pong') return;
@ -245,11 +331,22 @@ class VideoProvider extends Component {
logCode: 'video_provider_onwsclose',
}, 'Multiple video provider websocket connection closed.');
clearInterval(this.pingInterval);
this.clearWSHeartbeat();
VideoService.exitVideo();
// Media is currently tied to signaling state - so if signaling shuts down,
// media will shut down server-side. This cleans up our local state faster
// and notify the state change as failed so the UI rolls back to the placeholder
// avatar UI in the camera container
Object.keys(this.webRtcPeers).forEach((stream) => {
if (this.stopWebRTCPeer(stream, false)) {
notifyStreamStateChange(stream, 'failed');
}
});
this.setState({ socketOpen: false });
if (this.ws && this.ws.retryCount >= WS_MAX_RETRIES) {
this.terminateWs();
}
}
onWsOpen() {
@ -257,14 +354,21 @@ class VideoProvider extends Component {
logCode: 'video_provider_onwsopen',
}, 'Multiple video provider websocket connection opened.');
// Resend queued messages that happened when socket was not connected
while (this.wsQueue.length > 0) {
this.sendMessage(this.wsQueue.pop());
}
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL);
this._updateLastMsgTime();
this.setupWSHeartbeat();
this.setState({ socketOpen: true });
// Resend queued messages that happened when socket was not connected
Object.entries(this.wsQueues).forEach(([stream, queue]) => {
if (this.webRtcPeers[stream]) {
// Peer - send enqueued
while (queue.length > 0) {
this.sendMessage(queue.pop());
}
} else {
// No peer - delete queue
this.wsQueues[stream] = null;
}
});
}
findAllPrivilegedStreams () {
@ -343,25 +447,29 @@ class VideoProvider extends Component {
if (this.connectedToMediaServer()) {
const jsonMessage = JSON.stringify(message);
ws.send(jsonMessage, (error) => {
if (error) {
logger.error({
logCode: 'video_provider_ws_send_error',
extraInfo: {
errorMessage: error.message || 'Unknown',
errorCode: error.code,
},
}, 'Camera request failed to be sent to SFU');
}
});
try {
ws.send(jsonMessage);
} catch (error) {
logger.error({
logCode: 'video_provider_ws_send_error',
extraInfo: {
errorMessage: error.message || 'Unknown',
errorCode: error.code,
},
}, 'Camera request failed to be sent to SFU');
}
} else if (message.id !== 'stop') {
// No need to queue video stop messages
this.wsQueue.push(message);
const { cameraId } = message;
if (cameraId) {
if (this.wsQueues[cameraId] == null) this.wsQueues[cameraId] = [];
this.wsQueues[cameraId].push(message);
}
}
}
connectedToMediaServer() {
return this.ws.readyState === WebSocket.OPEN;
return this.ws && this.ws.readyState === ReconnectingWebSocket.OPEN;
}
processOutboundIceQueue(peer, role, stream) {
@ -495,10 +603,11 @@ class VideoProvider extends Component {
this.clearRestartTimers(stream);
}
this.destroyWebRTCPeer(stream);
return this.destroyWebRTCPeer(stream);
}
destroyWebRTCPeer(stream) {
let stopped = false;
const peer = this.webRtcPeers[stream];
const isLocal = VideoService.isLocalStream(stream);
const role = VideoService.getRole(isLocal);
@ -515,14 +624,19 @@ class VideoProvider extends Component {
peer.dispose();
}
delete this.outboundIceQueues[stream];
delete this.webRtcPeers[stream];
stopped = true;
} else {
logger.warn({
logCode: 'video_provider_destroywebrtcpeer_no_peer',
extraInfo: { cameraId: stream, role },
}, 'Trailing camera destroy request.');
}
delete this.outboundIceQueues[stream];
delete this.wsQueues[stream];
return stopped;
}
_createPublisher(stream, peerOptions) {
@ -665,10 +779,6 @@ class VideoProvider extends Component {
cameraId: stream,
role,
sdpOffer: offer,
meetingId: this.info.meetingId,
voiceBridge: this.info.voiceBridge,
userId: this.info.userId,
userName: this.info.userName,
bitrate,
record: VideoService.getRecord(),
mediaServer: VideoService.getMediaServerAdapter(),
@ -682,8 +792,8 @@ class VideoProvider extends Component {
},
}, `Camera offer generated. Role: ${role}`);
this.sendMessage(message);
this.setReconnectionTimeout(stream, isLocal, false);
this.sendMessage(message);
return;
}).catch(error => {
@ -742,7 +852,7 @@ class VideoProvider extends Component {
}
_onWebRTCError(error, stream, isLocal) {
const { intl } = this.props;
const { intl, streams } = this.props;
const { name: errorName, message: errorMessage } = error;
const errorLocale = intlClientErrors[errorName]
|| intlClientErrors[errorMessage]
@ -767,11 +877,16 @@ class VideoProvider extends Component {
// If it's a viewer, set the reconnection timeout. There's a good chance
// no local candidate was generated and it wasn't set.
const peer = this.webRtcPeers[stream];
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection);
const stillExists = streams.some(({ stream: streamId }) => streamId === stream);
if (stillExists) {
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection);
}
// second argument means it will only try to reconnect if
// it's a viewer instance (see stopWebRTCPeer restarting argument)
this.stopWebRTCPeer(stream, true);
this.stopWebRTCPeer(stream, stillExists);
}
}
@ -921,16 +1036,7 @@ class VideoProvider extends Component {
return; // Skip if the stream is already attached
}
// 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) {
if (VideoProvider.isAbleToAttach(peer)) {
this.attach(peer, video);
peer.attached = true;
@ -1079,7 +1185,7 @@ class VideoProvider extends Component {
}
handleSFUError(message) {
const { intl } = this.props;
const { intl, streams } = this.props;
const { code, reason, streamId } = message;
const isLocal = VideoService.isLocalStream(streamId);
const role = VideoService.getRole(isLocal);
@ -1097,15 +1203,22 @@ class VideoProvider extends Component {
if (isLocal) {
// The publisher instance received an error from the server. There's no reconnect,
// stop it.
VideoService.stopVideo(streamId);
VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200]));
VideoService.stopVideo(streamId);
} else {
this.stopWebRTCPeer(streamId, true);
const peer = this.webRtcPeers[streamId];
const stillExists = streams.some(({ stream }) => streamId === stream);
if (stillExists) {
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(streamId, isLocal, isEstablishedConnection);
}
this.stopWebRTCPeer(streamId, stillExists);
}
}
replacePCVideoTracks (streamId, mediaStream) {
let replaced = false;
replacePCVideoTracks(streamId, mediaStream) {
const peer = this.webRtcPeers[streamId];
const videoElement = this.getVideoElement(streamId);
@ -1115,26 +1228,24 @@ class VideoProvider extends Component {
const newTracks = mediaStream.getVideoTracks();
if (pc) {
try {
pc.getSenders().forEach((sender, index) => {
if (sender.track && sender.track.kind === 'video') {
const newTrack = newTracks[index];
if (newTrack == null) return;
sender.replaceTrack(newTrack);
replaced = true;
}
});
} catch (error) {
logger.error({
logCode: 'video_provider_replacepc_error',
extraInfo: { errorMessage: error.message, cameraId: streamId },
}, `Failed to replace peer connection tracks: ${error.message}`);
}
}
if (replaced) {
peer.localStream = mediaStream;
this.attach(peer, videoElement);
const trackReplacers = pc.getSenders().map(async (sender, index) => {
if (sender.track == null || sender.track.kind !== 'video') return false;
const newTrack = newTracks[index];
if (newTrack == null) return false;
try {
await sender.replaceTrack(newTrack);
return true;
} catch (error) {
logger.warn({
logCode: 'video_provider_replacepc_error',
extraInfo: { errorMessage: error.message, cameraId: streamId },
}, `Failed to replace peer connection tracks: ${error.message}`);
return false;
}
});
Promise.all(trackReplacers).then(() => {
this.attach(peer, videoElement);
});
}
}

View File

@ -25,6 +25,7 @@ export default withTracker(({ swapLayout, ...rest }) => {
totalNumberOfStreams,
isUserLocked: VideoService.isUserLocked(),
currentVideoPageIndex: VideoService.getCurrentVideoPageIndex(),
isMeteorConnected: Meteor.status().connected,
...rest,
};
})(VideoProviderContainer);

View File

@ -26,7 +26,7 @@ const VideoListItem = (props) => {
makeDragOperations, dragging, draggingOver, isRTL
} = props;
const [videoIsReady, setVideoIsReady] = useState(false);
const [videoDataLoaded, setVideoDataLoaded] = useState(false);
const [isStreamHealthy, setIsStreamHealthy] = useState(false);
const [isMirrored, setIsMirrored] = useState(VideoService.mirrorOwnWebcam(user?.userId));
const [isVideoSqueezed, setIsVideoSqueezed] = useState(false);
@ -41,7 +41,7 @@ const VideoListItem = (props) => {
const videoTag = useRef();
const videoContainer = useRef();
const shouldRenderReconnect = !isStreamHealthy && videoIsReady;
const videoIsReady = isStreamHealthy && videoDataLoaded;
const { animations } = Settings.application;
const talking = voiceUser?.talking;
@ -49,14 +49,11 @@ const VideoListItem = (props) => {
const { streamState } = e.detail;
const newHealthState = !isStreamStateUnhealthy(streamState);
e.stopPropagation();
if (newHealthState !== isStreamHealthy) {
setIsStreamHealthy(newHealthState);
}
setIsStreamHealthy(newHealthState);
};
const handleSetVideoIsReady = () => {
setVideoIsReady(true);
const onLoadedData = () => {
setVideoDataLoaded(true);
window.dispatchEvent(new Event('resize'));
/* used when re-sharing cameras after leaving a breakout room.
@ -71,10 +68,10 @@ const VideoListItem = (props) => {
onVideoItemMount(videoTag.current);
subscribeToStreamStateChange(cameraId, onStreamStateChange);
resizeObserver.observe(videoContainer.current);
videoTag?.current?.addEventListener('loadeddata', handleSetVideoIsReady);
videoTag?.current?.addEventListener('loadeddata', onLoadedData);
return () => {
videoTag?.current?.removeEventListener('loadeddata', handleSetVideoIsReady);
videoTag?.current?.removeEventListener('loadeddata', onLoadedData);
resizeObserver.disconnect();
};
}, []);
@ -96,10 +93,10 @@ const VideoListItem = (props) => {
// This is here to prevent the videos from freezing when they're
// moved around the dom by react, e.g., when changing the user status
// see https://bugs.chromium.org/p/chromium/issues/detail?id=382879
if (videoIsReady) {
if (videoDataLoaded) {
playElement(videoTag.current);
}
}, [videoIsReady]);
}, [videoDataLoaded]);
// component will unmount
useEffect(() => () => {
@ -130,7 +127,7 @@ const VideoListItem = (props) => {
<UserAvatarVideo
user={user}
voiceUser={voiceUser}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
squeezed={false}
/>
<Styled.BottomBar>
@ -158,7 +155,7 @@ const VideoListItem = (props) => {
>
<UserAvatarVideo
user={user}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
squeezed
/>
{renderSqueezedButton()}
@ -213,7 +210,7 @@ const VideoListItem = (props) => {
<Styled.VideoContainer>
<Styled.Video
mirrored={isMirrored}
unhealthyStream={shouldRenderReconnect}
unhealthyStream={videoDataLoaded && !isStreamHealthy}
data-test={isMirrored ? 'mirroredVideoContainer' : 'videoContainer'}
ref={videoTag}
autoPlay
@ -229,8 +226,6 @@ const VideoListItem = (props) => {
: (isVideoSqueezed)
? renderWebcamConnectingSqueezed()
: renderWebcamConnecting()}
{shouldRenderReconnect && <Styled.Reconnecting animations={animations} />}
</Styled.Content>
);
};

View File

@ -109,29 +109,6 @@ const LoadingText = styled(TextElipsis)`
font-size: 100%;
`;
const Reconnecting = styled.div`
position: absolute;
height: 100%;
width: 100%;
display: flex;
font-size: 2.5rem;
z-index: 1;
align-items: center;
justify-content: center;
background-color: transparent;
color: ${colorWhite};
&::before {
font-family: 'bbb-icons' !important;
content: "\\e949";
/* ascii code for the ellipsis character */
display: inline-block;
${({ animations }) => animations && css`
animation: ${rotate360} 2s infinite linear;
`}
}
`;
const VideoContainer = styled.div`
display: flex;
justify-content: center;
@ -180,7 +157,6 @@ export default {
Content,
WebcamConnecting,
LoadingText,
Reconnecting,
VideoContainer,
Video,
TopBar,

View File

@ -2,7 +2,10 @@ import logger from '/imports/startup/client/logger';
import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
import { SFU_BROKER_ERRORS } from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
const PING_INTERVAL_MS = 15000;
const WS_HEARTBEAT_OPTS = {
interval: 15000,
delay: 3000,
};
class BaseBroker {
static assembleError(code, reason) {
@ -21,13 +24,14 @@ class BaseBroker {
this.sfuComponent = sfuComponent;
this.ws = null;
this.webRtcPeer = null;
this.pingInterval = null;
this.wsHeartbeat = null;
this.started = false;
this.signallingTransportOpen = false;
this.logCodePrefix = `${this.sfuComponent}_broker`;
this.peerConfiguration = {};
this.onbeforeunload = this.onbeforeunload.bind(this);
this._onWSError = this._onWSError.bind(this);
window.addEventListener('beforeunload', this.onbeforeunload);
}
@ -63,48 +67,125 @@ class BaseBroker {
// To be implemented by inheritors
}
_onWSMessage(message) {
this._updateLastMsgTime();
this.onWSMessage(message);
}
onWSMessage(message) {
// To be implemented by inheritors
}
_onWSError(error) {
let normalizedError;
logger.error({
logCode: `${this.logCodePrefix}_websocket_error`,
extraInfo: {
errorMessage: error.name || error.message || 'Unknown error',
sfuComponent: this.sfuComponent,
}
}, 'WebSocket connection to SFU failed');
if (this.signallingTransportOpen) {
// 1301: "WEBSOCKET_DISCONNECTED", transport was already open
normalizedError = BaseBroker.assembleError(1301);
} else {
// 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment
normalizedError = BaseBroker.assembleError(1302);
}
this.onerror(normalizedError);
return normalizedError;
}
openWSConnection () {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = this.onWSMessage.bind(this);
this.ws.onmessage = this._onWSMessage.bind(this);
this.ws.onclose = () => {
// 1301: "WEBSOCKET_DISCONNECTED",
this.onerror(BaseBroker.assembleError(1301));
};
this.ws.onerror = (error) => {
logger.error({
logCode: `${this.logCodePrefix}_websocket_error`,
extraInfo: {
errorMessage: error.name || error.message || 'Unknown error',
sfuComponent: this.sfuComponent,
}
}, 'WebSocket connection to SFU failed');
if (this.signallingTransportOpen) {
// 1301: "WEBSOCKET_DISCONNECTED", transport was already open
this.onerror(BaseBroker.assembleError(1301));
} else {
// 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment
const normalized1302 = BaseBroker.assembleError(1302);
this.onerror(normalized1302);
return reject(normalized1302);
}
};
this.ws.onerror = (error) => reject(this._onWSError(error));
this.ws.onopen = () => {
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL_MS);
this.setupWSHeartbeat();
this.signallingTransportOpen = true;
return resolve();
};
});
}
closeWs() {
this.clearWSHeartbeat();
if (this.ws !== null) {
this.ws.onclose = function (){};
this.ws.close();
}
}
_updateLastMsgTime() {
this.ws.isAlive = true;
this.ws.lastMsgTime = Date.now();
}
_getTimeSinceLastMsg() {
return Date.now() - this.ws.lastMsgTime;
}
setupWSHeartbeat() {
if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null) return;
this.ws.isAlive = true;
this.wsHeartbeat = setInterval(() => {
if (this.ws.isAlive === false) {
logger.warn({
logCode: `${this.logCodePrefix}_ws_heartbeat_failed`,
}, `WS heartbeat failed (${this.sfuComponent})`);
this.closeWs();
this._onWSError(new Error('HeartbeatFailed'));
return;
}
if (this._getTimeSinceLastMsg() < (
WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
)) {
return;
}
this.ws.isAlive = false;
this.ping();
}, WS_HEARTBEAT_OPTS.interval);
this.ping();
}
clearWSHeartbeat() {
if (this.wsHeartbeat) {
clearInterval(this.wsHeartbeat);
}
}
sendMessage (message) {
const jsonMessage = JSON.stringify(message);
this.ws.send(jsonMessage);
try {
this.ws.send(jsonMessage);
} catch (error) {
logger.error({
logCode: `${this.logCodePrefix}_ws_send_error`,
extraInfo: {
errorName: error.name,
errorMessage: error.message,
sfuComponent: this.sfuComponent,
},
}, `Failed to send WebSocket message (${this.sfuComponent})`);
}
}
ping () {
@ -266,15 +347,7 @@ class BaseBroker {
this.webRtcPeer.peerConnection.onconnectionstatechange = null;
}
if (this.ws !== null) {
this.ws.onclose = function (){};
this.ws.close();
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.closeWs();
this.disposePeer();
this.started = false;

View File

@ -240,19 +240,23 @@ export default class WebRtcPeer extends EventEmitter2 {
}
getLocalStream() {
if (this.localStream) {
return this.localStream;
}
if (this.peerConnection) {
this.localStream = new MediaStream();
if (this.localStream == null) this.localStream = new MediaStream();
const senders = this.peerConnection.getSenders();
const oldTracks = this.localStream.getTracks();
senders.forEach(({ track }) => {
if (track) {
if (track && !oldTracks.includes(track)) {
this.localStream.addTrack(track);
}
});
oldTracks.forEach((oldTrack) => {
if (!senders.some(({ track }) => track && track.id === oldTrack.id)) {
this.localStream.removeTrack(oldTrack);
}
});
return this.localStream;
}

View File

@ -264,9 +264,18 @@ public:
enabled: true
kurento:
wsUrl: HOST
# Valid for video-provider. Time (ms) before its WS connection times out
# and tries to reconnect.
wsConnectionTimeout: 4000
cameraWsOptions:
# Valid for video-provider. Time (ms) before its WS connection times out
# and tries to reconnect.
wsConnectionTimeout: 4000
# maxRetries: max reconnection retries
maxRetries: 7
# debug: console trace logging for video-provider's ws
debug: false
heartbeat:
interval: 15000
delay: 3000
reconnectOnFailure: true
# Time in milis to wait for the browser to return a gUM call (used in video-preview)
gUMTimeout: 20000
# Controls whether ICE candidates should be signaled to bbb-webrtc-sfu.
@ -295,11 +304,13 @@ public:
bitrate: 1500
mediaTimeouts:
maxConnectionAttempts: 2
# Base screen media timeout (send|recv)
baseTimeout: 30000
# Max timeout: used as the max camera subscribe reconnection timeout. Each
# Base screen media timeout (send|recv) - first connections
baseTimeout: 20000
# Base screen media timeout (send|recv) - re-connections
baseReconnectionTimeout: 8000
# Max timeout: used as the max camera subscribe connection timeout. Each
# subscribe reattempt increases the reconnection timer up to this
maxTimeout: 60000
maxTimeout: 25000
timeoutIncreaseFactor: 1.5
constraints:
video: