14c92a3843
We currently use full renegotiation for audio, video, and screen sharing reconnections, which involves re-creating transports and signaling channels from scratch. While effective in some scenarios, this approach is slow and, especially with outbound cameras and screen sharing, prone to failures. To counter that, WebRTC provides a mechanism to restart ICE without needing to re-create the peer connection. This allows us to avoid full renegotiation and bypass some server-side signaling limitations. Implementing ICE restart should make outbound camera/screen sharing reconnections more reliable and faster. This commit implements the ICE restart procedure for all WebRTC components' *outbound* peers. It is based on bbb-webrtc-sfu >= v2.15.0-beta.0, which added support for ICE restart requests. This feature is *off by default*. To enable it, adjust the following flags: - `/etc/bigbluebutton/bbb-webrtc-sfu/production.yml`: `allowIceRestart: true` - `/etc/bigbluebutton/bbb-html5.yml`: `public.kurento.restartIce` * Refer to the inline documentation; this can be enabled on the client side per media type. * Note: The default max retries for audio is lower than for cameras/screen sharing (1 vs 3). This is because the full renegotiation process for audio is more reliable, so ICE restart is attempted first, followed by full renegotiation if necessary. This approach is less suitable for cameras/ screen sharing, where longer retry periods for ICE restart make sense since full renegotation there is... iffy. Endpoints that are inbound/`recvonly` only (client's perspective) do *not* support ICE restart yet. There are two main reasons: - Server-side changes are required to support `recvonly` endpoints, particularly the proper handling of the server’s `setup` role in the its SDPs during an ICE restart. These changes are too broad for now, so they are deferred to future releases (SFU@v2.16). - Full reconnections for `recvonly` endpoints are currently reliable, unlike for `send*` endpoints. ICE restarts could still provide benefits for `recvonly` endpoints, but we need the server updates first.
429 lines
13 KiB
JavaScript
Executable File
429 lines
13 KiB
JavaScript
Executable File
import Auth from '/imports/ui/services/auth';
|
|
import logger from '/imports/startup/client/logger';
|
|
import BridgeService from './service';
|
|
import ScreenshareBroker from '/imports/ui/services/bbb-webrtc-sfu/screenshare-broker';
|
|
import { setIsSharing, screenShareEndAlert, setOutputDeviceId } from '/imports/ui/components/screenshare/service';
|
|
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 BRIDGE_NAME = 'kurento'
|
|
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
|
|
const SEND_ROLE = 'send';
|
|
const RECV_ROLE = 'recv';
|
|
const DEFAULT_VOLUME = 1;
|
|
|
|
// the error-code mapping is bridge specific; that's why it's not in the errors util
|
|
const ERROR_MAP = {
|
|
1301: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_DISCONNECTED,
|
|
1302: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED,
|
|
1305: SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED,
|
|
1307: SCREENSHARING_ERRORS.ICE_STATE_FAILED,
|
|
1310: SCREENSHARING_ERRORS.ENDED_WHILE_STARTING,
|
|
};
|
|
|
|
const mapErrorCode = (error) => {
|
|
const { errorCode } = error;
|
|
const mappedError = ERROR_MAP[errorCode];
|
|
|
|
if (errorCode == null || mappedError == null) return error;
|
|
error.errorCode = mappedError.errorCode;
|
|
error.errorMessage = mappedError.errorMessage;
|
|
error.message = mappedError.errorMessage;
|
|
|
|
return error;
|
|
}
|
|
|
|
export default class KurentoScreenshareBridge {
|
|
constructor() {
|
|
this.role;
|
|
this.broker;
|
|
this._gdmStream;
|
|
this.hasAudio = false;
|
|
this.connectionAttempts = 0;
|
|
this.reconnecting = false;
|
|
this.reconnectionTimeout;
|
|
this._restartIntervalMs = null;
|
|
this.startedOnce = false;
|
|
this.outputDeviceId = null;
|
|
}
|
|
|
|
get restartIntervalMs() {
|
|
return this._restartIntervalMs || BridgeService.BASE_MEDIA_TIMEOUT();
|
|
}
|
|
|
|
set restartIntervalMs(value) {
|
|
this._restartIntervalMs = value;
|
|
}
|
|
|
|
get gdmStream() {
|
|
return this._gdmStream;
|
|
}
|
|
|
|
set gdmStream(stream) {
|
|
this._gdmStream = stream;
|
|
}
|
|
|
|
_shouldReconnect() {
|
|
// Sender/presenter reconnect is *not* implemented yet
|
|
return this.reconnectionTimeout == null && this.role === RECV_ROLE;
|
|
}
|
|
|
|
/**
|
|
* Get the RTCPeerConnection object related to the screensharing stream.
|
|
* @returns {Object} The RTCPeerConnection object related to the presenter/
|
|
* viewer peer. If there's no stream being shared, returns
|
|
* null.
|
|
*/
|
|
getPeerConnection() {
|
|
try {
|
|
let peerConnection = null;
|
|
|
|
if (this.broker && this.broker.webRtcPeer) {
|
|
peerConnection = this.broker.webRtcPeer.peerConnection;
|
|
}
|
|
|
|
return peerConnection;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
inboundStreamReconnect() {
|
|
const currentRestartIntervalMs = this.restartIntervalMs;
|
|
|
|
logger.warn({
|
|
logCode: 'screenshare_viewer_reconnect',
|
|
extraInfo: {
|
|
reconnecting: this.reconnecting,
|
|
role: this.role,
|
|
bridge: BRIDGE_NAME,
|
|
},
|
|
}, 'Screenshare viewer is reconnecting');
|
|
|
|
// Cleanly stop everything before triggering a reconnect
|
|
this._stop();
|
|
// Create new reconnect interval time
|
|
this.restartIntervalMs = BridgeService.getNextReconnectionInterval(currentRestartIntervalMs);
|
|
this.view(this.hasAudio).then(() => {
|
|
this.clearReconnectionTimeout();
|
|
}).catch((error) => {
|
|
// Error handling is a no-op because it will be "handled" in handleViewerFailure
|
|
logger.debug({
|
|
logCode: 'screenshare_reconnect_failed',
|
|
extraInfo: {
|
|
errorCode: error.errorCode,
|
|
errorMessage: error.errorMessage,
|
|
reconnecting: this.reconnecting,
|
|
role: this.role,
|
|
bridge: BRIDGE_NAME
|
|
},
|
|
}, 'Screensharing reconnect failed');
|
|
});
|
|
}
|
|
|
|
handleConnectionTimeoutExpiry() {
|
|
this.reconnecting = true;
|
|
|
|
switch (this.role) {
|
|
case RECV_ROLE:
|
|
return this.inboundStreamReconnect();
|
|
|
|
// Sender/presenter reconnect is *not* implemented yet
|
|
case SEND_ROLE:
|
|
default:
|
|
this.reconnecting = false;
|
|
logger.error({
|
|
logCode: 'screenshare_wont_reconnect',
|
|
extraInfo: {
|
|
role: this.broker?.role || this.role,
|
|
started: !!(this.broker?.started),
|
|
bridge: BRIDGE_NAME,
|
|
},
|
|
}, 'Screen sharing will not reconnect');
|
|
break;
|
|
}
|
|
}
|
|
|
|
maxConnectionAttemptsReached () {
|
|
return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS();
|
|
}
|
|
|
|
scheduleReconnect({
|
|
overrideTimeout,
|
|
} = { }) {
|
|
if (this.reconnectionTimeout == null) {
|
|
let nextRestartInterval = this.restartIntervalMs;
|
|
if (typeof overrideTimeout === 'number') nextRestartInterval = overrideTimeout;
|
|
|
|
this.reconnectionTimeout = setTimeout(
|
|
this.handleConnectionTimeoutExpiry.bind(this),
|
|
nextRestartInterval,
|
|
);
|
|
}
|
|
}
|
|
|
|
clearReconnectionTimeout () {
|
|
this.reconnecting = false;
|
|
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT();
|
|
|
|
if (this.reconnectionTimeout) {
|
|
clearTimeout(this.reconnectionTimeout);
|
|
this.reconnectionTimeout = null;
|
|
}
|
|
}
|
|
|
|
setVolume(volume) {
|
|
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
|
|
|
if (mediaElement) {
|
|
if (typeof volume === 'number' && volume >= 0 && volume <= 1) {
|
|
mediaElement.volume = volume;
|
|
}
|
|
|
|
return mediaElement.volume;
|
|
}
|
|
|
|
return DEFAULT_VOLUME;
|
|
}
|
|
|
|
getVolume() {
|
|
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
|
|
|
if (mediaElement) return mediaElement.volume;
|
|
|
|
return DEFAULT_VOLUME;
|
|
}
|
|
|
|
handleViewerStart() {
|
|
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
|
|
|
if (mediaElement && this.broker && this.broker.webRtcPeer) {
|
|
const stream = this.broker.webRtcPeer.getRemoteStream();
|
|
|
|
if (this.hasAudio && this.outputDeviceId && typeof this.outputDeviceId === 'string') {
|
|
setOutputDeviceId(this.outputDeviceId);
|
|
}
|
|
|
|
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio);
|
|
}
|
|
|
|
this.startedOnce = true;
|
|
this.clearReconnectionTimeout();
|
|
this.connectionAttempts = 0;
|
|
}
|
|
|
|
handleBrokerFailure(error) {
|
|
mapErrorCode(error);
|
|
const { errorMessage, errorCode } = error;
|
|
|
|
logger.error({
|
|
logCode: 'screenshare_broker_failure',
|
|
extraInfo: {
|
|
errorCode,
|
|
errorMessage,
|
|
role: this.broker.role,
|
|
started: this.broker.started,
|
|
reconnecting: this.reconnecting,
|
|
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 (this._shouldReconnect()) {
|
|
// this.broker.started => whether the reconnect should happen immediately.
|
|
// 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;
|
|
}
|
|
|
|
async view(options = {
|
|
hasAudio: false,
|
|
outputDeviceId: null,
|
|
}) {
|
|
const SETTINGS = window.meetingClientSettings;
|
|
const SFU_CONFIG = SETTINGS.public.kurento;
|
|
const SFU_URL = SFU_CONFIG.wsUrl;
|
|
const OFFERING = SFU_CONFIG.screenshare.subscriberOffering;
|
|
const SIGNAL_CANDIDATES = SFU_CONFIG.signalCandidates;
|
|
const TRACE_LOGS = SFU_CONFIG.traceLogs;
|
|
const GATHERING_TIMEOUT = SFU_CONFIG.gatheringTimeout;
|
|
this.hasAudio = options.hasAudio;
|
|
this.outputDeviceId = options.outputDeviceId;
|
|
this.role = RECV_ROLE;
|
|
const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
|
|
const brokerOptions = {
|
|
iceServers,
|
|
userName: Auth.fullname,
|
|
hasAudio: options.hasAudio,
|
|
offering: OFFERING,
|
|
mediaServer: BridgeService.getMediaServerAdapter(),
|
|
signalCandidates: SIGNAL_CANDIDATES,
|
|
forceRelay: shouldForceRelay(),
|
|
traceLogs: TRACE_LOGS,
|
|
gatheringTimeout: GATHERING_TIMEOUT,
|
|
// ICE restart only works for publishers right now - recvonly full
|
|
// reconnection works ok without it.
|
|
restartIce: false,
|
|
};
|
|
|
|
this.broker = new ScreenshareBroker(
|
|
Auth.authenticateURL(SFU_URL),
|
|
BridgeService.getConferenceBridge(),
|
|
Auth.userID,
|
|
Auth.meetingID,
|
|
this.role,
|
|
brokerOptions,
|
|
);
|
|
|
|
this.broker.onstart = this.handleViewerStart.bind(this);
|
|
this.broker.onerror = this.handleBrokerFailure.bind(this);
|
|
if (!this.reconnecting) {
|
|
this.broker.onended = this.handleEnded.bind(this);
|
|
}
|
|
return this.broker.view().finally(this.scheduleReconnect.bind(this));
|
|
}
|
|
|
|
handlePresenterStart() {
|
|
logger.info({
|
|
logCode: 'screenshare_presenter_start_success',
|
|
}, 'Screenshare presenter started succesfully');
|
|
this.clearReconnectionTimeout();
|
|
this.startedOnce = true;
|
|
this.reconnecting = false;
|
|
this.connectionAttempts = 0;
|
|
}
|
|
|
|
handleEnded() {
|
|
screenShareEndAlert();
|
|
}
|
|
|
|
share(stream, onFailure, contentType) {
|
|
return new Promise(async (resolve, reject) => {
|
|
const SETTINGS = window.meetingClientSettings;
|
|
const SFU_CONFIG = SETTINGS.public.kurento;
|
|
const SFU_URL = SFU_CONFIG.wsUrl;
|
|
const SIGNAL_CANDIDATES = SFU_CONFIG.signalCandidates;
|
|
const TRACE_LOGS = SFU_CONFIG.traceLogs;
|
|
const { screenshare: NETWORK_PRIORITY } = SETTINGS.public.media.networkPriorities || {};
|
|
const GATHERING_TIMEOUT = SFU_CONFIG.gatheringTimeout;
|
|
const {
|
|
enabled: RESTART_ICE = false,
|
|
retries: RESTART_ICE_RETRIES = 3,
|
|
} = SFU_CONFIG.restartIce?.screenshare || {};
|
|
this.onerror = onFailure;
|
|
this.connectionAttempts += 1;
|
|
this.role = SEND_ROLE;
|
|
this.hasAudio = BridgeService.streamHasAudioTrack(stream);
|
|
this.gdmStream = stream;
|
|
|
|
const onerror = (error) => {
|
|
const normalizedError = this.handleBrokerFailure(error);
|
|
if (!this.broker.started) {
|
|
// Broker hasn't started - if there are retries left, try again.
|
|
if (this.maxConnectionAttemptsReached()) {
|
|
this.clearReconnectionTimeout();
|
|
this.connectionAttempts = 0;
|
|
onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
|
|
reject(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
|
|
}
|
|
} else if (!this._shouldReconnect()) {
|
|
// Broker has started - should reconnect? If it shouldn't, end it.
|
|
onFailure(normalizedError);
|
|
}
|
|
};
|
|
|
|
const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
|
|
const options = {
|
|
iceServers,
|
|
userName: Auth.fullname,
|
|
stream,
|
|
hasAudio: this.hasAudio,
|
|
contentType: contentType,
|
|
bitrate: BridgeService.BASE_BITRATE(),
|
|
offering: true,
|
|
mediaServer: BridgeService.getMediaServerAdapter(),
|
|
signalCandidates: SIGNAL_CANDIDATES,
|
|
forceRelay: shouldForceRelay(),
|
|
traceLogs: TRACE_LOGS,
|
|
networkPriority: NETWORK_PRIORITY,
|
|
gatheringTimeout: GATHERING_TIMEOUT,
|
|
restartIce: RESTART_ICE,
|
|
restartIceMaxRetries: RESTART_ICE_RETRIES,
|
|
};
|
|
|
|
this.broker = new ScreenshareBroker(
|
|
Auth.authenticateURL(SFU_URL),
|
|
BridgeService.getConferenceBridge(),
|
|
Auth.userID,
|
|
Auth.meetingID,
|
|
this.role,
|
|
options,
|
|
);
|
|
|
|
this.broker.onerror = onerror.bind(this);
|
|
this.broker.onstreamended = this.stop.bind(this);
|
|
this.broker.onstart = this.handlePresenterStart.bind(this);
|
|
this.broker.onended = this.handleEnded.bind(this);
|
|
|
|
this.broker.share().then(() => {
|
|
this.scheduleReconnect();
|
|
return resolve();
|
|
}).catch((error) => reject(mapErrorCode(error)));
|
|
});
|
|
}
|
|
|
|
// This is a reconnect-safe internal method. Should be used when one wants
|
|
// to clear the internal components (ie broker, connection timeouts) without
|
|
// affecting externally controlled components (ie gDM stream,
|
|
// media tag, connectionAttempts, ...)
|
|
_stop() {
|
|
if (this.broker) {
|
|
this.broker.stop();
|
|
// Checks if this session is a sharer and if it's not reconnecting
|
|
// If that's the case, clear the local sharing state in screen sharing UI
|
|
// component tracker to be extra sure we won't have any client-side state
|
|
// inconsistency - prlanzarin
|
|
if (this.broker && this.broker.role === SEND_ROLE && !this.reconnecting) {
|
|
setIsSharing(false);
|
|
}
|
|
this.broker = null;
|
|
}
|
|
|
|
this.clearReconnectionTimeout();
|
|
}
|
|
|
|
stop() {
|
|
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
|
|
|
this._stop();
|
|
this.connectionAttempts = 0;
|
|
|
|
if (mediaElement && typeof mediaElement.pause === 'function') {
|
|
mediaElement.pause();
|
|
mediaElement.srcObject = null;
|
|
}
|
|
|
|
if (this.gdmStream) {
|
|
MediaStreamUtils.stopMediaStreamTracks(this.gdmStream);
|
|
this.gdmStream = null;
|
|
}
|
|
|
|
this.outputDeviceId = null;
|
|
}
|
|
}
|