bigbluebutton-Github/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js
Arthurk12 c2e90ede11 feat(screenshare): gain node
Splits screenshare stream into video and audio and adds gain node to audio
stream in order to permit volume control by the user. Volume is normalized
between [0, 2](muted and 2x boost).
2021-11-12 16:30:39 +00:00

390 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';
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 BRIDGE_NAME = 'kurento'
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
const SEND_ROLE = 'send';
const RECV_ROLE = 'recv';
const DEFAULT_GAIN = 1;
const MAX_GAIN = 2;
// 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,
}
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;
}
/**
* 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;
}
}
outboundStreamReconnect() {
const currentRestartIntervalMs = this.restartIntervalMs;
const stream = this.gdmStream;
logger.warn({
logCode: 'screenshare_presenter_reconnect',
extraInfo: {
reconnecting: this.reconnecting,
role: this.role,
bridge: BRIDGE_NAME
},
}, `Screenshare presenter session is reconnecting`);
this.stop();
this.restartIntervalMs = BridgeService.getNextReconnectionInterval(currentRestartIntervalMs);
this.share(stream, this.onerror).then(() => {
this.clearReconnectionTimeout();
}).catch(error => {
// Error handling is a no-op because it will be "handled" in handlePresenterFailure
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');
});
}
inboundStreamReconnect() {
const currentRestartIntervalMs = this.restartIntervalMs;
logger.warn({
logCode: 'screenshare_viewer_reconnect',
extraInfo: {
reconnecting: this.reconnecting,
role: this.role,
bridge: BRIDGE_NAME
},
}, `Screenshare viewer session 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();
case SEND_ROLE:
return this.outboundStreamReconnect();
default:
this.reconnecting = false;
logger.error({
logCode: 'screenshare_invalid_role'
}, 'Screen sharing with invalid role, wont reconnect');
break;
}
}
maxConnectionAttemptsReached () {
return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS;
}
scheduleReconnect () {
if (this.reconnectionTimeout == null) {
this.reconnectionTimeout = setTimeout(
this.handleConnectionTimeoutExpiry.bind(this),
this.restartIntervalMs
);
}
}
clearReconnectionTimeout () {
this.reconnecting = false;
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
if (this.reconnectionTimeout) {
clearTimeout(this.reconnectionTimeout);
this.reconnectionTimeout = null;
}
}
setVolume(volume) {
this.gainNode.gain.value = volume * MAX_GAIN;
}
getVolume() {
return this.gainNode ? this.gainNode.gain.value : DEFAULT_GAIN;
}
addGainNode(audioStream) {
if (audioStream && this.gainNode == null) {
this.audioContext = new AudioContext();
this.contextSource = this.audioContext.createMediaStreamSource(audioStream);
this.gainNode = this.audioContext.createGain();
this.contextSource.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
this.gainNode.gain.value = this.getVolume();
}
if (!audioStream) {
logger.warn({
logCode: 'screenshare_stream_missing',
extraInfo: {
role: this.role,
bridge: BRIDGE_NAME,
},
}, 'Screenshare audio stream is missing');
}
}
removeGainNode() {
if (this.gainNode) {
this.gainNode.disconnect();
this.gainNode = null;
}
if (this.contextSource) {
this.contextSource.disconnect();
this.contextSource = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
}
handleViewerStart() {
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
if (mediaElement && this.broker && this.broker.webRtcPeer) {
const stream = this.broker.webRtcPeer.getRemoteStream();
if (this.broker.hasAudio) {
this.addGainNode(stream);
}
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, true);
}
this.clearReconnectionTimeout();
}
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.broker.started) {
this.scheduleReconnect();
}
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,
};
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.maxConnectionAttemptsReached()) {
this.clearReconnectionTimeout();
this.connectionAttempts = 0;
onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
return reject(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
}
};
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,
};
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(reject);
});
};
stop() {
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
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.removeGainNode();
if (mediaElement && typeof mediaElement.pause === 'function') {
mediaElement.pause();
mediaElement.srcObject = null;
}
this.gdmStream = null;
this.clearReconnectionTimeout();
}
}