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:
commit
e2dc7da98a
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ export default withTracker(({ swapLayout, ...rest }) => {
|
||||
totalNumberOfStreams,
|
||||
isUserLocked: VideoService.isUserLocked(),
|
||||
currentVideoPageIndex: VideoService.getCurrentVideoPageIndex(),
|
||||
isMeteorConnected: Meteor.status().connected,
|
||||
...rest,
|
||||
};
|
||||
})(VideoProviderContainer);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user