0e162f1cda
RTCRTPSender exposes DSCP marking via `networkPriority` in the encodings configuration dictionaries. That should allow us to control QoS priorities for different media streams, eg audio with higher network priority than video. The only browser that implements that right now is Chromium. To use this, the public.app.media.networkPriorities configuration in settings.yml. Audio, camera and screenshare priorities can be controlled separately. For further info on the possible values, see: - https://www.w3.org/TR/webrtc-priority/ - https://datatracker.ietf.org/doc/html/rfc8837#section-5
372 lines
11 KiB
JavaScript
Executable File
372 lines
11 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 { setSharingScreen, screenShareEndAlert } 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';
|
|
|
|
const SFU_CONFIG = Meteor.settings.public.kurento;
|
|
const SFU_URL = SFU_CONFIG.wsUrl;
|
|
const OFFERING = SFU_CONFIG.screenshare.subscriberOffering;
|
|
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
|
|
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
|
|
const { screenshare: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
|
|
|
|
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 = BridgeService.BASE_MEDIA_TIMEOUT;
|
|
}
|
|
|
|
get gdmStream() {
|
|
return this._gdmStream;
|
|
}
|
|
|
|
set gdmStream(stream) {
|
|
this._gdmStream = stream;
|
|
}
|
|
|
|
_shouldReconnect() {
|
|
// Sender/presenter reconnect is *not* implemented yet
|
|
return this.broker.started && 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(immediate = false) {
|
|
if (this.reconnectionTimeout == null) {
|
|
const nextRestartInterval = immediate ? 0 : this.restartIntervalMs;
|
|
|
|
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();
|
|
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio);
|
|
}
|
|
|
|
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}`);
|
|
|
|
// 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);
|
|
}
|
|
|
|
return error;
|
|
}
|
|
|
|
async view(hasAudio = false) {
|
|
this.hasAudio = hasAudio;
|
|
this.role = RECV_ROLE;
|
|
const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
|
|
const options = {
|
|
iceServers,
|
|
userName: Auth.fullname,
|
|
hasAudio,
|
|
offering: OFFERING,
|
|
mediaServer: BridgeService.getMediaServerAdapter(),
|
|
signalCandidates: SIGNAL_CANDIDATES,
|
|
forceRelay: shouldForceRelay(),
|
|
traceLogs: TRACE_LOGS,
|
|
};
|
|
|
|
this.broker = new ScreenshareBroker(
|
|
Auth.authenticateURL(SFU_URL),
|
|
BridgeService.getConferenceBridge(),
|
|
Auth.userID,
|
|
Auth.meetingID,
|
|
this.role,
|
|
options,
|
|
);
|
|
|
|
this.broker.onstart = this.handleViewerStart.bind(this);
|
|
this.broker.onerror = this.handleBrokerFailure.bind(this);
|
|
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.reconnecting = false;
|
|
this.connectionAttempts = 0;
|
|
}
|
|
|
|
handleEnded() {
|
|
screenShareEndAlert();
|
|
}
|
|
|
|
share(stream, onFailure) {
|
|
return new Promise(async (resolve, reject) => {
|
|
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,
|
|
bitrate: BridgeService.BASE_BITRATE,
|
|
offering: true,
|
|
mediaServer: BridgeService.getMediaServerAdapter(),
|
|
signalCandidates: SIGNAL_CANDIDATES,
|
|
forceRelay: shouldForceRelay(),
|
|
traceLogs: TRACE_LOGS,
|
|
networkPriority: NETWORK_PRIORITY,
|
|
};
|
|
|
|
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) {
|
|
setSharingScreen(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;
|
|
}
|
|
}
|
|
}
|