Merge branch 'u22-screenshare18102020' of https://github.com/prlanzarin/bigbluebutton-1 into u23-ilgiardino
This commit is contained in:
commit
7eb91f9273
@ -10,6 +10,7 @@ object ScreenshareModel {
|
||||
status.voiceConf = ""
|
||||
status.screenshareConf = ""
|
||||
status.timestamp = ""
|
||||
status.hasAudio = false
|
||||
}
|
||||
|
||||
def getScreenshareStarted(status: ScreenshareModel): Boolean = {
|
||||
@ -79,6 +80,14 @@ object ScreenshareModel {
|
||||
def getTimestamp(status: ScreenshareModel): String = {
|
||||
status.timestamp
|
||||
}
|
||||
|
||||
def setHasAudio(status: ScreenshareModel, hasAudio: Boolean): Unit = {
|
||||
status.hasAudio = hasAudio
|
||||
}
|
||||
|
||||
def getHasAudio(status: ScreenshareModel): Boolean = {
|
||||
status.hasAudio
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenshareModel {
|
||||
@ -90,4 +99,5 @@ class ScreenshareModel {
|
||||
private var voiceConf: String = ""
|
||||
private var screenshareConf: String = ""
|
||||
private var timestamp: String = ""
|
||||
private var hasAudio = false
|
||||
}
|
||||
|
@ -25,9 +25,10 @@ trait GetScreenshareStatusReqMsgHdlr {
|
||||
val vidWidth = ScreenshareModel.getScreenshareVideoWidth(liveMeeting.screenshareModel)
|
||||
val vidHeight = ScreenshareModel.getScreenshareVideoHeight(liveMeeting.screenshareModel)
|
||||
val timestamp = ScreenshareModel.getTimestamp(liveMeeting.screenshareModel)
|
||||
val hasAudio = ScreenshareModel.getHasAudio(liveMeeting.screenshareModel)
|
||||
|
||||
val body = ScreenshareRtmpBroadcastStartedEvtMsgBody(voiceConf, screenshareConf,
|
||||
stream, vidWidth, vidHeight, timestamp)
|
||||
stream, vidWidth, vidHeight, timestamp, hasAudio)
|
||||
val event = ScreenshareRtmpBroadcastStartedEvtMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
|
||||
def handle(msg: ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
|
||||
def broadcastEvent(voiceConf: String, screenshareConf: String, stream: String, vidWidth: Int, vidHeight: Int,
|
||||
timestamp: String): BbbCommonEnvCoreMsg = {
|
||||
timestamp: String, hasAudio: Boolean): BbbCommonEnvCoreMsg = {
|
||||
|
||||
val routing = Routing.addMsgToClientRouting(
|
||||
MessageTypes.BROADCAST_TO_MEETING,
|
||||
@ -23,7 +23,7 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
)
|
||||
|
||||
val body = ScreenshareRtmpBroadcastStartedEvtMsgBody(voiceConf, screenshareConf,
|
||||
stream, vidWidth, vidHeight, timestamp)
|
||||
stream, vidWidth, vidHeight, timestamp, hasAudio)
|
||||
val event = ScreenshareRtmpBroadcastStartedEvtMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
@ -41,12 +41,13 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
ScreenshareModel.setVoiceConf(liveMeeting.screenshareModel, msg.body.voiceConf)
|
||||
ScreenshareModel.setScreenshareConf(liveMeeting.screenshareModel, msg.body.screenshareConf)
|
||||
ScreenshareModel.setTimestamp(liveMeeting.screenshareModel, msg.body.timestamp)
|
||||
ScreenshareModel.setHasAudio(liveMeeting.screenshareModel, msg.body.hasAudio)
|
||||
|
||||
log.info("START broadcast ALLOWED when isBroadcastingRTMP=false")
|
||||
|
||||
// Notify viewers in the meeting that there's an rtmp stream to view
|
||||
val msgEvent = broadcastEvent(msg.body.voiceConf, msg.body.screenshareConf, msg.body.stream,
|
||||
msg.body.vidWidth, msg.body.vidHeight, msg.body.timestamp)
|
||||
msg.body.vidWidth, msg.body.vidHeight, msg.body.timestamp, msg.body.hasAudio)
|
||||
bus.outGW.send(msgEvent)
|
||||
} else {
|
||||
log.info("START broadcast NOT ALLOWED when isBroadcastingRTMP=true")
|
||||
|
@ -83,7 +83,7 @@ public class FreeswitchConferenceEventListener implements ConferenceEventListene
|
||||
if (((ScreenshareRTMPBroadcastEvent) event).getBroadcast()) {
|
||||
ScreenshareRTMPBroadcastEvent evt = (ScreenshareRTMPBroadcastEvent) event;
|
||||
vcs.deskShareRTMPBroadcastStarted(evt.getRoom(), evt.getBroadcastingStreamUrl(),
|
||||
evt.getVideoWidth(), evt.getVideoHeight(), evt.getTimestamp());
|
||||
evt.getVideoWidth(), evt.getVideoHeight(), evt.getTimestamp(), evt.getHasAudio());
|
||||
} else {
|
||||
ScreenshareRTMPBroadcastEvent evt = (ScreenshareRTMPBroadcastEvent) event;
|
||||
vcs.deskShareRTMPBroadcastStopped(evt.getRoom(), evt.getBroadcastingStreamUrl(),
|
||||
|
@ -55,7 +55,8 @@ public interface IVoiceConferenceService {
|
||||
String streamname,
|
||||
Integer videoWidth,
|
||||
Integer videoHeight,
|
||||
String timestamp);
|
||||
String timestamp,
|
||||
boolean hasAudio);
|
||||
|
||||
void deskShareRTMPBroadcastStopped(String room,
|
||||
String streamname,
|
||||
|
@ -25,6 +25,7 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
private String streamUrl;
|
||||
private Integer vw;
|
||||
private Integer vh;
|
||||
private boolean hasAudio;
|
||||
|
||||
private final String SCREENSHARE_SUFFIX = "-SCREENSHARE";
|
||||
|
||||
@ -46,6 +47,10 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
|
||||
public void setVideoHeight(Integer vh) {this.vh = vh;}
|
||||
|
||||
public void setHasAudio(boolean hasAudio) {
|
||||
this.hasAudio = hasAudio;
|
||||
}
|
||||
|
||||
public Integer getVideoHeight() {return vh;}
|
||||
|
||||
public Integer getVideoWidth() {return vw;}
|
||||
@ -65,4 +70,8 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
public boolean getBroadcast() {
|
||||
return broadcast;
|
||||
}
|
||||
|
||||
public boolean getHasAudio() {
|
||||
return hasAudio;
|
||||
}
|
||||
}
|
||||
|
@ -237,13 +237,14 @@ class VoiceConferenceService(healthz: HealthzService,
|
||||
streamname: String,
|
||||
vw: java.lang.Integer,
|
||||
vh: java.lang.Integer,
|
||||
timestamp: String
|
||||
timestamp: String,
|
||||
hasAudio: Boolean
|
||||
) {
|
||||
|
||||
val header = BbbCoreVoiceConfHeader(ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg.NAME, voiceConfId)
|
||||
val body = ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgBody(voiceConf = voiceConfId, screenshareConf = voiceConfId,
|
||||
stream = streamname, vidWidth = vw.intValue(), vidHeight = vh.intValue(),
|
||||
timestamp)
|
||||
timestamp, hasAudio)
|
||||
val envelope = BbbCoreEnvelope(ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg.NAME, Map("voiceConf" -> voiceConfId))
|
||||
|
||||
val msg = new ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg(header, body)
|
||||
|
@ -24,7 +24,7 @@ case class ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg(
|
||||
extends VoiceStandardMsg
|
||||
case class ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgBody(voiceConf: String, screenshareConf: String,
|
||||
stream: String, vidWidth: Int, vidHeight: Int,
|
||||
timestamp: String)
|
||||
timestamp: String, hasAudio: Boolean)
|
||||
|
||||
/**
|
||||
* Sent to clients to notify them of an RTMP stream starting.
|
||||
@ -37,7 +37,7 @@ case class ScreenshareRtmpBroadcastStartedEvtMsg(
|
||||
extends BbbCoreMsg
|
||||
case class ScreenshareRtmpBroadcastStartedEvtMsgBody(voiceConf: String, screenshareConf: String,
|
||||
stream: String, vidWidth: Int, vidHeight: Int,
|
||||
timestamp: String)
|
||||
timestamp: String, hasAudio: Boolean)
|
||||
|
||||
/**
|
||||
* Send by FS that RTMP stream has stopped.
|
||||
|
@ -82,7 +82,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
</script>
|
||||
<script src="compatibility/adapter.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/sip.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/kurento-extension.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/kurento-utils.js?v=VERSION" language="javascript"></script>
|
||||
</head>
|
||||
<body style="background-color: #06172A">
|
||||
|
@ -0,0 +1,61 @@
|
||||
import {
|
||||
SFU_CLIENT_SIDE_ERRORS,
|
||||
SFU_SERVER_SIDE_ERRORS
|
||||
} from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
|
||||
|
||||
// Mapped getDisplayMedia errors. These are bridge agnostic
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
|
||||
const GDM_ERRORS = {
|
||||
// Fallback error: 1130
|
||||
1130: 'GetDisplayMediaGenericError',
|
||||
1131: 'AbortError',
|
||||
1132: 'InvalidStateError',
|
||||
1133: 'OverconstrainedError',
|
||||
1134: 'TypeError',
|
||||
1135: 'NotFoundError',
|
||||
1136: 'NotAllowedError',
|
||||
1137: 'NotSupportedError',
|
||||
1138: 'NotReadableError',
|
||||
};
|
||||
|
||||
// Import as many bridge specific errors you want in this utilitary and shove
|
||||
// them into the error class slots down below.
|
||||
const CLIENT_SIDE_ERRORS = {
|
||||
1101: "SIGNALLING_TRANSPORT_DISCONNECTED",
|
||||
1102: "SIGNALLING_TRANSPORT_CONNECTION_FAILED",
|
||||
1104: "SCREENSHARE_PLAY_FAILED",
|
||||
1105: "PEER_NEGOTIATION_FAILED",
|
||||
1107: "ICE_STATE_FAILED",
|
||||
1120: "MEDIA_TIMEOUT",
|
||||
1121: "UNKNOWN_ERROR",
|
||||
};
|
||||
|
||||
const SERVER_SIDE_ERRORS = {
|
||||
...SFU_SERVER_SIDE_ERRORS,
|
||||
}
|
||||
|
||||
const AGGREGATED_ERRORS = {
|
||||
...CLIENT_SIDE_ERRORS,
|
||||
...SERVER_SIDE_ERRORS,
|
||||
...GDM_ERRORS,
|
||||
}
|
||||
|
||||
const expandErrors = () => {
|
||||
const expandedErrors = Object.keys(AGGREGATED_ERRORS).reduce((map, key) => {
|
||||
map[AGGREGATED_ERRORS[key]] = { errorCode: key, errorMessage: AGGREGATED_ERRORS[key] };
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return { ...AGGREGATED_ERRORS, ...expandedErrors };
|
||||
}
|
||||
|
||||
const SCREENSHARING_ERRORS = expandErrors();
|
||||
|
||||
export {
|
||||
GDM_ERRORS,
|
||||
BRIDGE_SERVER_SIDE_ERRORS,
|
||||
BRIDGE_CLIENT_SIDE_ERRORS,
|
||||
// All errors, [code]: [message]
|
||||
// Expanded errors. It's AGGREGATED + message: { errorCode, errorMessage }
|
||||
SCREENSHARING_ERRORS,
|
||||
}
|
@ -1,236 +1,290 @@
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import BridgeService from './service';
|
||||
import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun } from '/imports/utils/fetchStunTurnServers';
|
||||
import playAndRetry from '/imports/utils/mediaElementPlayRetry';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import BridgeService from './service';
|
||||
import ScreenshareBroker from '/imports/ui/services/bbb-webrtc-sfu/screenshare-broker';
|
||||
import { setSharingScreen } 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 CHROME_DEFAULT_EXTENSION_KEY = SFU_CONFIG.chromeDefaultExtensionKey;
|
||||
const CHROME_CUSTOM_EXTENSION_KEY = SFU_CONFIG.chromeExtensionKey;
|
||||
const CHROME_SCREENSHARE_SOURCES = SFU_CONFIG.screenshare.chromeScreenshareSources;
|
||||
const FIREFOX_SCREENSHARE_SOURCE = SFU_CONFIG.screenshare.firefoxScreenshareSource;
|
||||
|
||||
const BRIDGE_NAME = 'kurento'
|
||||
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
|
||||
const SEND_ROLE = 'send';
|
||||
const RECV_ROLE = 'recv';
|
||||
|
||||
const CHROME_EXTENSION_KEY = CHROME_CUSTOM_EXTENSION_KEY === 'KEY' ? CHROME_DEFAULT_EXTENSION_KEY : CHROME_CUSTOM_EXTENSION_KEY;
|
||||
// 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 getUserId = () => Auth.userID;
|
||||
const mapErrorCode = (error) => {
|
||||
const { errorCode } = error;
|
||||
const mappedError = ERROR_MAP[errorCode];
|
||||
|
||||
const getMeetingId = () => Auth.meetingID;
|
||||
if (errorCode == null || mappedError == null) return error;
|
||||
error.errorCode = mappedError.errorCode;
|
||||
error.errorMessage = mappedError.errorMessage;
|
||||
error.message = mappedError.errorMessage;
|
||||
|
||||
const getUsername = () => Auth.fullname;
|
||||
|
||||
const getSessionToken = () => Auth.sessionToken;
|
||||
return error;
|
||||
}
|
||||
|
||||
export default class KurentoScreenshareBridge {
|
||||
static normalizeError(error = {}) {
|
||||
const errorMessage = error.name || error.message || error.reason || 'Unknown error';
|
||||
const errorCode = error.code || undefined;
|
||||
const errorReason = error.reason || error.id || 'Undefined reason';
|
||||
|
||||
return { errorMessage, errorCode, errorReason };
|
||||
constructor() {
|
||||
this.role;
|
||||
this.broker;
|
||||
this._gdmStream;
|
||||
this.hasAudio = false;
|
||||
this.connectionAttempts = 0;
|
||||
this.reconnecting = false;
|
||||
this.reconnectionTimeout;
|
||||
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
|
||||
}
|
||||
|
||||
static handlePresenterFailure(error, started = false) {
|
||||
const normalizedError = KurentoScreenshareBridge.normalizeError(error);
|
||||
if (!started) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_presenter_error_failed_to_connect',
|
||||
extraInfo: { ...normalizedError },
|
||||
}, `Screenshare presenter failed when trying to start due to ${normalizedError.errorMessage}`);
|
||||
} else {
|
||||
logger.error({
|
||||
logCode: 'screenshare_presenter_error_failed_after_success',
|
||||
extraInfo: { ...normalizedError },
|
||||
}, `Screenshare presenter failed during working session due to ${normalizedError.errorMessage}`);
|
||||
get gdmStream() {
|
||||
return this._gdmStream;
|
||||
}
|
||||
|
||||
set gdmStream(stream) {
|
||||
this._gdmStream = stream;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
static handleViewerFailure(error, started = false) {
|
||||
const normalizedError = KurentoScreenshareBridge.normalizeError(error);
|
||||
if (!started) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_error_failed_to_connect',
|
||||
extraInfo: { ...normalizedError },
|
||||
}, `Screenshare viewer failed when trying to start due to ${normalizedError.errorMessage}`);
|
||||
} else {
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_error_failed_after_success',
|
||||
extraInfo: { ...normalizedError },
|
||||
}, `Screenshare viewer failed during working session due to ${normalizedError.errorMessage}`);
|
||||
maxConnectionAttemptsReached () {
|
||||
return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS;
|
||||
}
|
||||
|
||||
scheduleReconnect () {
|
||||
if (this.reconnectionTimeout == null) {
|
||||
this.reconnectionTimeout = setTimeout(
|
||||
this.handleConnectionTimeoutExpiry.bind(this),
|
||||
this.restartIntervalMs
|
||||
);
|
||||
}
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
static playElement(screenshareMediaElement) {
|
||||
const mediaTagPlayed = () => {
|
||||
logger.info({
|
||||
logCode: 'screenshare_media_play_success',
|
||||
}, 'Screenshare media played successfully');
|
||||
clearReconnectionTimeout () {
|
||||
this.reconnecting = false;
|
||||
this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
|
||||
|
||||
if (this.reconnectionTimeout) {
|
||||
clearTimeout(this.reconnectionTimeout);
|
||||
this.reconnectionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
if (screenshareMediaElement.paused) {
|
||||
// Tag isn't playing yet. Play it.
|
||||
screenshareMediaElement.play()
|
||||
.then(mediaTagPlayed)
|
||||
.catch((error) => {
|
||||
// NotAllowedError equals autoplay issues, fire autoplay handling event.
|
||||
// This will be handled in the screenshare react component.
|
||||
if (error.name === 'NotAllowedError') {
|
||||
logger.error({
|
||||
logCode: 'screenshare_error_autoplay',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, 'Screenshare play failed due to autoplay error');
|
||||
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
|
||||
{ detail: { mediaElement: screenshareMediaElement } });
|
||||
window.dispatchEvent(tagFailedEvent);
|
||||
} else {
|
||||
// Tag failed for reasons other than autoplay. Log the error and
|
||||
// try playing again a few times until it works or fails for good
|
||||
const played = playAndRetry(screenshareMediaElement);
|
||||
if (!played) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_error_media_play_failed',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, `Screenshare media play failed due to ${error.name}`);
|
||||
} else {
|
||||
mediaTagPlayed();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Media tag is already playing, so log a success. This is really a
|
||||
// logging fallback for a case that shouldn't happen. But if it does
|
||||
// (ie someone re-enables the autoPlay prop in the element), then it
|
||||
// means the stream is playing properly and it'll be logged.
|
||||
mediaTagPlayed();
|
||||
}
|
||||
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);
|
||||
return this.broker.view().finally(this.scheduleReconnect.bind(this));
|
||||
}
|
||||
|
||||
static screenshareElementLoadAndPlay(stream, element, muted) {
|
||||
element.muted = muted;
|
||||
element.pause();
|
||||
element.srcObject = stream;
|
||||
KurentoScreenshareBridge.playElement(element);
|
||||
handlePresenterStart() {
|
||||
logger.info({
|
||||
logCode: 'screenshare_presenter_start_success',
|
||||
}, 'Screenshare presenter started succesfully');
|
||||
this.clearReconnectionTimeout();
|
||||
this.reconnecting = false;
|
||||
this.connectionAttempts = 0;
|
||||
}
|
||||
|
||||
kurentoViewLocalPreview() {
|
||||
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
||||
const { webRtcPeer } = window.kurentoManager.kurentoScreenshare;
|
||||
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;
|
||||
|
||||
if (webRtcPeer) {
|
||||
const stream = webRtcPeer.getLocalStream();
|
||||
KurentoScreenshareBridge.screenshareElementLoadAndPlay(stream, screenshareMediaElement, true);
|
||||
}
|
||||
}
|
||||
const onerror = (error) => {
|
||||
const normalizedError = this.handleBrokerFailure(error);
|
||||
if (this.maxConnectionAttemptsReached()) {
|
||||
this.clearReconnectionTimeout();
|
||||
this.connectionAttempts = 0;
|
||||
onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
|
||||
|
||||
async kurentoViewScreen() {
|
||||
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
|
||||
let iceServers = [];
|
||||
let started = false;
|
||||
|
||||
try {
|
||||
iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken());
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
logCode: 'screenshare_viewer_fetchstunturninfo_error',
|
||||
extraInfo: { error },
|
||||
}, 'Screenshare bridge failed to fetch STUN/TURN info, using default');
|
||||
iceServers = getMappedFallbackStun();
|
||||
} finally {
|
||||
const options = {
|
||||
wsUrl: Auth.authenticateURL(SFU_URL),
|
||||
iceServers,
|
||||
logger,
|
||||
userName: getUsername(),
|
||||
};
|
||||
|
||||
const onFail = (error) => {
|
||||
KurentoScreenshareBridge.handleViewerFailure(error, started);
|
||||
};
|
||||
|
||||
// Callback for the kurento-extension.js script. It's called when the whole
|
||||
// negotiation with SFU is successful. This will load the stream into the
|
||||
// screenshare media element and play it manually.
|
||||
const onSuccess = () => {
|
||||
started = true;
|
||||
const { webRtcPeer } = window.kurentoManager.kurentoVideo;
|
||||
if (webRtcPeer) {
|
||||
const stream = webRtcPeer.getRemoteStream();
|
||||
KurentoScreenshareBridge.screenshareElementLoadAndPlay(
|
||||
stream,
|
||||
screenshareMediaElement,
|
||||
true,
|
||||
);
|
||||
return reject(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
window.kurentoWatchVideo(
|
||||
SCREENSHARE_VIDEO_TAG,
|
||||
BridgeService.getConferenceBridge(),
|
||||
getUserId(),
|
||||
getMeetingId(),
|
||||
onFail,
|
||||
onSuccess,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
kurentoExitVideo() {
|
||||
window.kurentoExitVideo();
|
||||
}
|
||||
|
||||
async kurentoShareScreen(onFail, stream) {
|
||||
let iceServers = [];
|
||||
try {
|
||||
iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken());
|
||||
} catch (error) {
|
||||
logger.error({ logCode: 'screenshare_presenter_fetchstunturninfo_error' },
|
||||
|
||||
'Screenshare bridge failed to fetch STUN/TURN info, using default');
|
||||
iceServers = getMappedFallbackStun();
|
||||
} finally {
|
||||
const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
|
||||
const options = {
|
||||
wsUrl: Auth.authenticateURL(SFU_URL),
|
||||
chromeExtension: CHROME_EXTENSION_KEY,
|
||||
chromeScreenshareSources: CHROME_SCREENSHARE_SOURCES,
|
||||
firefoxScreenshareSource: FIREFOX_SCREENSHARE_SOURCE,
|
||||
iceServers,
|
||||
logger,
|
||||
userName: getUsername(),
|
||||
userName: Auth.fullname,
|
||||
stream,
|
||||
hasAudio: this.hasAudio,
|
||||
};
|
||||
|
||||
let started = false;
|
||||
|
||||
const failureCallback = (error) => {
|
||||
const normalizedError = KurentoScreenshareBridge.handlePresenterFailure(error, started);
|
||||
onFail(normalizedError);
|
||||
};
|
||||
|
||||
const successCallback = () => {
|
||||
started = true;
|
||||
logger.info({
|
||||
logCode: 'screenshare_presenter_start_success',
|
||||
}, 'Screenshare presenter started succesfully');
|
||||
};
|
||||
|
||||
options.stream = stream || undefined;
|
||||
|
||||
window.kurentoShareScreen(
|
||||
SCREENSHARE_VIDEO_TAG,
|
||||
this.broker = new ScreenshareBroker(
|
||||
Auth.authenticateURL(SFU_URL),
|
||||
BridgeService.getConferenceBridge(),
|
||||
getUserId(),
|
||||
getMeetingId(),
|
||||
failureCallback,
|
||||
successCallback,
|
||||
Auth.userID,
|
||||
Auth.meetingID,
|
||||
this.role,
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
kurentoExitScreenShare() {
|
||||
window.kurentoExitScreenShare();
|
||||
this.broker.onerror = onerror.bind(this);
|
||||
this.broker.onstreamended = this.stop.bind(this);
|
||||
this.broker.onstart = this.handlePresenterStart.bind(this);
|
||||
|
||||
this.broker.share().then(() => {
|
||||
this.scheduleReconnect();
|
||||
return resolve();
|
||||
}).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
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.role === SEND_ROLE && !this.reconnecting) setSharingScreen(false);
|
||||
this.broker = null;
|
||||
}
|
||||
this.gdmStream = null;
|
||||
this.clearReconnectionTimeout();
|
||||
}
|
||||
}
|
||||
|
@ -1,37 +1,66 @@
|
||||
import Meetings from '/imports/api/meetings';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun } from '/imports/utils/fetchStunTurnServers';
|
||||
import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play';
|
||||
import { SCREENSHARING_ERRORS } from './errors';
|
||||
|
||||
const {
|
||||
constraints: GDM_CONSTRAINTS,
|
||||
mediaTimeouts: MEDIA_TIMEOUTS,
|
||||
} = Meteor.settings.public.kurento.screenshare;
|
||||
const {
|
||||
baseTimeout: BASE_MEDIA_TIMEOUT,
|
||||
maxTimeout: MAX_MEDIA_TIMEOUT,
|
||||
maxConnectionAttempts: MAX_CONN_ATTEMPTS,
|
||||
timeoutIncreaseFactor: TIMEOUT_INCREASE_FACTOR,
|
||||
} = MEDIA_TIMEOUTS;
|
||||
|
||||
const hasDisplayMedia = (typeof navigator.getDisplayMedia === 'function'
|
||||
const HAS_DISPLAY_MEDIA = (typeof navigator.getDisplayMedia === 'function'
|
||||
|| (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function'));
|
||||
|
||||
const getConferenceBridge = () => Meetings.findOne().voiceProp.voiceConf;
|
||||
|
||||
const normalizeGetDisplayMediaError = (error) => {
|
||||
return SCREENSHARING_ERRORS[error.name] || SCREENSHARING_ERRORS.GetDisplayMediaGenericError;
|
||||
};
|
||||
|
||||
const getBoundGDM = () => {
|
||||
if (typeof navigator.getDisplayMedia === 'function') {
|
||||
return navigator.getDisplayMedia.bind(navigator);
|
||||
} else if (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function') {
|
||||
return navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices);
|
||||
}
|
||||
}
|
||||
|
||||
const getScreenStream = async () => {
|
||||
const gDMCallback = (stream) => {
|
||||
// Some older Chromium variants choke on gDM when audio: true by NOT generating
|
||||
// a promise rejection AND not generating a valid input screen stream, need to
|
||||
// work around that manually for now - prlanzarin
|
||||
if (stream == null) {
|
||||
return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError);
|
||||
}
|
||||
|
||||
if (typeof stream.getVideoTracks === 'function'
|
||||
&& typeof constraints.video === 'object') {
|
||||
stream.getVideoTracks().forEach((track) => {
|
||||
if (typeof track.applyConstraints === 'function') {
|
||||
track.applyConstraints(constraints.video).catch((error) => {
|
||||
&& typeof GDM_CONSTRAINTS.video === 'object') {
|
||||
stream.getVideoTracks().forEach(track => {
|
||||
if (typeof track.applyConstraints === 'function') {
|
||||
track.applyConstraints(GDM_CONSTRAINTS.video).catch(error => {
|
||||
logger.warn({
|
||||
logCode: 'screenshare_videoconstraint_failed',
|
||||
extraInfo: { errorName: error.name, errorCode: error.code },
|
||||
},
|
||||
'Error applying screenshare video constraint');
|
||||
'Error applying screenshare video constraint');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof stream.getAudioTracks === 'function'
|
||||
&& typeof constraints.audio === 'object') {
|
||||
stream.getAudioTracks().forEach((track) => {
|
||||
if (typeof track.applyConstraints === 'function') {
|
||||
track.applyConstraints(constraints.audio).catch((error) => {
|
||||
&& typeof GDM_CONSTRAINTS.audio === 'object') {
|
||||
stream.getAudioTracks().forEach(track => {
|
||||
if (typeof track.applyConstraints === 'function') {
|
||||
track.applyConstraints(GDM_CONSTRAINTS.audio).catch(error => {
|
||||
logger.warn({
|
||||
logCode: 'screenshare_audioconstraint_failed',
|
||||
extraInfo: { errorName: error.name, errorCode: error.code },
|
||||
@ -44,39 +73,81 @@ const getScreenStream = async () => {
|
||||
return Promise.resolve(stream);
|
||||
};
|
||||
|
||||
const constraints = hasDisplayMedia ? GDM_CONSTRAINTS : null;
|
||||
const getDisplayMedia = getBoundGDM();
|
||||
|
||||
// getDisplayMedia isn't supported, generate no stream and let the legacy
|
||||
// constraint fetcher work its way on kurento-extension.js
|
||||
if (constraints == null) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (typeof navigator.getDisplayMedia === 'function') {
|
||||
return navigator.getDisplayMedia(constraints)
|
||||
if (typeof getDisplayMedia === 'function') {
|
||||
return getDisplayMedia(GDM_CONSTRAINTS)
|
||||
.then(gDMCallback)
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
const normalizedError = normalizeGetDisplayMediaError(error);
|
||||
logger.error({
|
||||
logCode: 'screenshare_getdisplaymedia_failed',
|
||||
extraInfo: { errorName: error.name, errorCode: error.code },
|
||||
extraInfo: { errorCode: normalizedError.errorCode, errorMessage: normalizedError.errorMessage },
|
||||
}, 'getDisplayMedia call failed');
|
||||
return Promise.resolve();
|
||||
});
|
||||
} if (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function') {
|
||||
return navigator.mediaDevices.getDisplayMedia(constraints)
|
||||
.then(gDMCallback)
|
||||
.catch((error) => {
|
||||
logger.error({
|
||||
logCode: 'screenshare_getdisplaymedia_failed',
|
||||
extraInfo: { errorName: error.name, errorCode: error.code },
|
||||
}, 'getDisplayMedia call failed');
|
||||
return Promise.resolve();
|
||||
return Promise.reject(normalizedError);
|
||||
});
|
||||
} else {
|
||||
// getDisplayMedia isn't supported, error its way out
|
||||
return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError);
|
||||
}
|
||||
};
|
||||
|
||||
const getIceServers = (sessionToken) => {
|
||||
return fetchWebRTCMappedStunTurnServers(sessionToken).catch(error => {
|
||||
logger.error({
|
||||
logCode: 'screenshare_fetchstunturninfo_error',
|
||||
extraInfo: { error }
|
||||
}, 'Screenshare bridge failed to fetch STUN/TURN info');
|
||||
return getMappedFallbackStun();
|
||||
});
|
||||
}
|
||||
|
||||
const getNextReconnectionInterval = (oldInterval) => {
|
||||
return Math.min(
|
||||
TIMEOUT_INCREASE_FACTOR * oldInterval,
|
||||
MAX_MEDIA_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
const streamHasAudioTrack = (stream) => {
|
||||
return stream
|
||||
&& typeof stream.getAudioTracks === 'function'
|
||||
&& stream.getAudioTracks().length >= 1;
|
||||
}
|
||||
|
||||
const dispatchAutoplayHandlingEvent = (mediaElement) => {
|
||||
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
|
||||
{ detail: { mediaElement } });
|
||||
window.dispatchEvent(tagFailedEvent);
|
||||
}
|
||||
|
||||
const screenshareLoadAndPlayMediaStream = (stream, mediaElement, muted) => {
|
||||
return loadAndPlayMediaStream(stream, mediaElement, muted).catch(error => {
|
||||
// NotAllowedError equals autoplay issues, fire autoplay handling event.
|
||||
// This will be handled in the screenshare react component.
|
||||
if (error.name === 'NotAllowedError') {
|
||||
logger.error({
|
||||
logCode: 'screenshare_error_autoplay',
|
||||
extraInfo: { errorName: error.name },
|
||||
}, 'Screen share media play failed: autoplay error');
|
||||
dispatchAutoplayHandlingEvent(mediaElement);
|
||||
} else {
|
||||
throw {
|
||||
errorCode: SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode,
|
||||
errorMessage: error.message || SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorMessage,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
hasDisplayMedia,
|
||||
HAS_DISPLAY_MEDIA,
|
||||
getConferenceBridge,
|
||||
getScreenStream,
|
||||
getIceServers,
|
||||
getNextReconnectionInterval,
|
||||
streamHasAudioTrack,
|
||||
screenshareLoadAndPlayMediaStream,
|
||||
BASE_MEDIA_TIMEOUT,
|
||||
MAX_CONN_ATTEMPTS,
|
||||
};
|
||||
|
@ -1,101 +1,89 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { styles } from './styles.scss';
|
||||
import DesktopShare from './desktop-share/component';
|
||||
import ActionsDropdown from './actions-dropdown/container';
|
||||
import AudioControlsContainer from '../audio/audio-controls/container';
|
||||
import JoinVideoOptionsContainer from '../video-provider/video-button/container';
|
||||
import CaptionsButtonContainer from '/imports/ui/components/actions-bar/captions/container';
|
||||
import PresentationOptionsContainer from './presentation-options/component';
|
||||
import { ACTIONSBAR_HEIGHT } from '/imports/ui/components/layout/layout-manager';
|
||||
import React, { PureComponent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { styles } from './styles.scss';
|
||||
import ActionsDropdown from './actions-dropdown/container';
|
||||
import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container';
|
||||
import AudioControlsContainer from '../audio/audio-controls/container';
|
||||
import JoinVideoOptionsContainer from '../video-provider/video-button/container';
|
||||
import CaptionsButtonContainer from '/imports/ui/components/actions-bar/captions/container';
|
||||
import PresentationOptionsContainer from './presentation-options/component';
|
||||
import { ACTIONSBAR_HEIGHT } from '/imports/ui/components/layout/layout-manager';
|
||||
|
||||
class ActionsBar extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
amIPresenter,
|
||||
handleShareScreen,
|
||||
handleUnshareScreen,
|
||||
isVideoBroadcasting,
|
||||
amIModerator,
|
||||
screenSharingCheck,
|
||||
enableVideo,
|
||||
isLayoutSwapped,
|
||||
toggleSwapLayout,
|
||||
handleTakePresenter,
|
||||
intl,
|
||||
isSharingVideo,
|
||||
screenShareEndAlert,
|
||||
stopExternalVideoShare,
|
||||
screenshareDataSavingSetting,
|
||||
isCaptionsAvailable,
|
||||
isMeteorConnected,
|
||||
isPollingEnabled,
|
||||
isPresentationDisabled,
|
||||
isThereCurrentPresentation,
|
||||
allowExternalVideo,
|
||||
} = this.props;
|
||||
class ActionsBar extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
amIPresenter,
|
||||
amIModerator,
|
||||
enableVideo,
|
||||
isLayoutSwapped,
|
||||
toggleSwapLayout,
|
||||
handleTakePresenter,
|
||||
intl,
|
||||
isSharingVideo,
|
||||
stopExternalVideoShare,
|
||||
isCaptionsAvailable,
|
||||
isMeteorConnected,
|
||||
isPollingEnabled,
|
||||
isPresentationDisabled,
|
||||
isThereCurrentPresentation,
|
||||
allowExternalVideo,
|
||||
} = this.props;
|
||||
|
||||
const actionBarClasses = {};
|
||||
const actionBarClasses = {};
|
||||
|
||||
actionBarClasses[styles.centerWithActions] = amIPresenter;
|
||||
actionBarClasses[styles.center] = true;
|
||||
actionBarClasses[styles.mobileLayoutSwapped] = isLayoutSwapped && amIPresenter;
|
||||
actionBarClasses[styles.centerWithActions] = amIPresenter;
|
||||
actionBarClasses[styles.center] = true;
|
||||
actionBarClasses[styles.mobileLayoutSwapped] = isLayoutSwapped && amIPresenter;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.actionsbar}
|
||||
style={{
|
||||
height: ACTIONSBAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div className={styles.left}>
|
||||
<ActionsDropdown {...{
|
||||
amIPresenter,
|
||||
amIModerator,
|
||||
isPollingEnabled,
|
||||
allowExternalVideo,
|
||||
handleTakePresenter,
|
||||
intl,
|
||||
isSharingVideo,
|
||||
stopExternalVideoShare,
|
||||
isMeteorConnected,
|
||||
return (
|
||||
<div
|
||||
className={styles.actionsbar}
|
||||
style={{
|
||||
height: ACTIONSBAR_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
{isCaptionsAvailable
|
||||
? (
|
||||
<CaptionsButtonContainer {...{ intl }} />
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className={cx(actionBarClasses)}>
|
||||
<AudioControlsContainer />
|
||||
{enableVideo
|
||||
? (
|
||||
<JoinVideoOptionsContainer />
|
||||
)
|
||||
: null}
|
||||
<DesktopShare {...{
|
||||
handleShareScreen,
|
||||
handleUnshareScreen,
|
||||
isVideoBroadcasting,
|
||||
amIPresenter,
|
||||
screenSharingCheck,
|
||||
screenShareEndAlert,
|
||||
isMeteorConnected,
|
||||
screenshareDataSavingSetting,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
{isLayoutSwapped && !isPresentationDisabled
|
||||
? (
|
||||
<PresentationOptionsContainer
|
||||
toggleSwapLayout={toggleSwapLayout}
|
||||
isThereCurrentPresentation={isThereCurrentPresentation}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
>
|
||||
<div className={styles.left}>
|
||||
<ActionsDropdown {...{
|
||||
amIPresenter,
|
||||
amIModerator,
|
||||
isPollingEnabled,
|
||||
allowExternalVideo,
|
||||
handleTakePresenter,
|
||||
intl,
|
||||
isSharingVideo,
|
||||
stopExternalVideoShare,
|
||||
isMeteorConnected,
|
||||
}}
|
||||
/>
|
||||
{isCaptionsAvailable
|
||||
? (
|
||||
<CaptionsButtonContainer {...{ intl }} />
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className={cx(actionBarClasses)}>
|
||||
<AudioControlsContainer />
|
||||
{enableVideo
|
||||
? (
|
||||
<JoinVideoOptionsContainer />
|
||||
)
|
||||
: null}
|
||||
<ScreenshareButtonContainer {...{
|
||||
amIPresenter,
|
||||
isMeteorConnected,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
{isLayoutSwapped && !isPresentationDisabled
|
||||
? (
|
||||
<PresentationOptionsContainer
|
||||
toggleSwapLayout={toggleSwapLayout}
|
||||
isThereCurrentPresentation={isThereCurrentPresentation}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,12 +11,8 @@ import Service from './service';
|
||||
import ExternalVideoService from '/imports/ui/components/external-video-player/service';
|
||||
import CaptionsService from '/imports/ui/components/captions/service';
|
||||
import {
|
||||
shareScreen,
|
||||
unshareScreen,
|
||||
isVideoBroadcasting,
|
||||
screenShareEndAlert,
|
||||
dataSavingSetting,
|
||||
} from '../screenshare/service';
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
|
||||
import MediaService, {
|
||||
getSwapLayout,
|
||||
@ -31,10 +27,6 @@ export default withTracker(() => ({
|
||||
amIPresenter: Service.amIPresenter(),
|
||||
amIModerator: Service.amIModerator(),
|
||||
stopExternalVideoShare: ExternalVideoService.stopWatching,
|
||||
handleShareScreen: onFail => shareScreen(onFail),
|
||||
handleUnshareScreen: () => unshareScreen(),
|
||||
isVideoBroadcasting: isVideoBroadcasting(),
|
||||
screenSharingCheck: getFromUserSettings('bbb_enable_screen_sharing', Meteor.settings.public.kurento.enableScreensharing),
|
||||
enableVideo: getFromUserSettings('bbb_enable_video', Meteor.settings.public.kurento.enableVideo),
|
||||
isLayoutSwapped: getSwapLayout() && shouldEnableSwapLayout(),
|
||||
toggleSwapLayout: MediaService.toggleSwapLayout,
|
||||
@ -42,8 +34,6 @@ export default withTracker(() => ({
|
||||
currentSlidHasContent: PresentationService.currentSlidHasContent(),
|
||||
parseCurrentSlideContent: PresentationService.parseCurrentSlideContent,
|
||||
isSharingVideo: Service.isSharingVideo(),
|
||||
screenShareEndAlert,
|
||||
screenshareDataSavingSetting: dataSavingSetting(),
|
||||
isCaptionsAvailable: CaptionsService.isCaptionsAvailable(),
|
||||
isMeteorConnected: Meteor.status().connected,
|
||||
isPollingEnabled: POLLING_ENABLED,
|
||||
|
@ -1,209 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import browser from 'browser-detect';
|
||||
import Button from '/imports/ui/components/button/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { notify } from '/imports/ui/services/notification';
|
||||
import cx from 'classnames';
|
||||
import Modal from '/imports/ui/components/modal/simple/component';
|
||||
import { withModalMounter } from '../../modal/service';
|
||||
import { styles } from '../styles';
|
||||
import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
amIPresenter: PropTypes.bool.isRequired,
|
||||
handleShareScreen: PropTypes.func.isRequired,
|
||||
handleUnshareScreen: PropTypes.func.isRequired,
|
||||
isVideoBroadcasting: PropTypes.bool.isRequired,
|
||||
screenSharingCheck: PropTypes.bool.isRequired,
|
||||
screenShareEndAlert: PropTypes.func.isRequired,
|
||||
isMeteorConnected: PropTypes.bool.isRequired,
|
||||
screenshareDataSavingSetting: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
desktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
|
||||
description: 'Desktop Share option label',
|
||||
},
|
||||
lockedDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.lockedDesktopShareLabel',
|
||||
description: 'Desktop locked Share option label',
|
||||
},
|
||||
stopDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareLabel',
|
||||
description: 'Stop Desktop Share option label',
|
||||
},
|
||||
desktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
|
||||
description: 'adds context to desktop share option',
|
||||
},
|
||||
stopDesktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
|
||||
description: 'adds context to stop desktop share option',
|
||||
},
|
||||
genericError: {
|
||||
id: 'app.screenshare.genericError',
|
||||
description: 'error message for when screensharing fails with unknown error',
|
||||
},
|
||||
NotAllowedError: {
|
||||
id: 'app.screenshare.notAllowed',
|
||||
description: 'error message when screen access was not granted',
|
||||
},
|
||||
NotSupportedError: {
|
||||
id: 'app.screenshare.notSupportedError',
|
||||
description: 'error message when trying to share screen in unsafe environments',
|
||||
},
|
||||
screenShareNotSupported: {
|
||||
id: 'app.media.screenshare.notSupported',
|
||||
descriptions: 'error message when trying share screen on unsupported browsers',
|
||||
},
|
||||
screenShareUnavailable: {
|
||||
id: 'app.media.screenshare.unavailable',
|
||||
descriptions: 'title for unavailable screen share modal',
|
||||
},
|
||||
NotReadableError: {
|
||||
id: 'app.screenshare.notReadableError',
|
||||
description: 'error message when the browser failed to capture the screen',
|
||||
},
|
||||
1108: {
|
||||
id: 'app.deskshare.iceConnectionStateError',
|
||||
description: 'Error message for ice connection state failure',
|
||||
},
|
||||
2000: {
|
||||
id: 'app.sfu.mediaServerConnectionError2000',
|
||||
description: 'Error message fired when the SFU cannot connect to the media server',
|
||||
},
|
||||
2001: {
|
||||
id: 'app.sfu.mediaServerOffline2001',
|
||||
description: 'error message when SFU is offline',
|
||||
},
|
||||
2002: {
|
||||
id: 'app.sfu.mediaServerNoResources2002',
|
||||
description: 'Error message fired when the media server lacks disk, CPU or FDs',
|
||||
},
|
||||
2003: {
|
||||
id: 'app.sfu.mediaServerRequestTimeout2003',
|
||||
description: 'Error message fired when requests are timing out due to lack of resources',
|
||||
},
|
||||
2021: {
|
||||
id: 'app.sfu.serverIceGatheringFailed2021',
|
||||
description: 'Error message fired when the server cannot enact ICE gathering',
|
||||
},
|
||||
2022: {
|
||||
id: 'app.sfu.serverIceStateFailed2022',
|
||||
description: 'Error message fired when the server endpoint transitioned to a FAILED ICE state',
|
||||
},
|
||||
2200: {
|
||||
id: 'app.sfu.mediaGenericError2200',
|
||||
description: 'Error message fired when the SFU component generated a generic error',
|
||||
},
|
||||
2202: {
|
||||
id: 'app.sfu.invalidSdp2202',
|
||||
description: 'Error message fired when the clients provides an invalid SDP',
|
||||
},
|
||||
2203: {
|
||||
id: 'app.sfu.noAvailableCodec2203',
|
||||
description: 'Error message fired when the server has no available codec for the client',
|
||||
},
|
||||
});
|
||||
|
||||
const BROWSER_RESULTS = browser();
|
||||
const isMobileBrowser = (BROWSER_RESULTS ? BROWSER_RESULTS.mobile : false)
|
||||
|| (BROWSER_RESULTS && BROWSER_RESULTS.os
|
||||
? BROWSER_RESULTS.os.includes('Android') // mobile flag doesn't always work
|
||||
: false);
|
||||
const IS_SAFARI = BROWSER_RESULTS.name === 'safari';
|
||||
|
||||
const DesktopShare = ({
|
||||
intl,
|
||||
handleShareScreen,
|
||||
handleUnshareScreen,
|
||||
isVideoBroadcasting,
|
||||
amIPresenter,
|
||||
screenSharingCheck,
|
||||
screenShareEndAlert,
|
||||
isMeteorConnected,
|
||||
screenshareDataSavingSetting,
|
||||
mountModal,
|
||||
}) => {
|
||||
// This is the failure callback that will be passed to the /api/screenshare/kurento.js
|
||||
// script on the presenter's call
|
||||
const onFail = (normalizedError) => {
|
||||
const { errorCode, errorMessage, errorReason } = normalizedError;
|
||||
const error = errorCode || errorMessage || errorReason;
|
||||
// We have a properly mapped error for this. Exit screenshare and show a toast notification
|
||||
if (intlMessages[error]) {
|
||||
window.kurentoExitScreenShare();
|
||||
notify(intl.formatMessage(intlMessages[error]), 'error', 'desktop');
|
||||
} else {
|
||||
// Unmapped error. Log it (so we can infer what's going on), close screenSharing
|
||||
// session and display generic error message
|
||||
logger.error({
|
||||
logCode: 'screenshare_default_error',
|
||||
extraInfo: {
|
||||
errorCode, errorMessage, errorReason,
|
||||
},
|
||||
}, 'Default error handler for screenshare');
|
||||
window.kurentoExitScreenShare();
|
||||
notify(intl.formatMessage(intlMessages.genericError), 'error', 'desktop');
|
||||
}
|
||||
// Don't trigger the screen share end alert if presenter click to cancel on screen share dialog
|
||||
if (error !== 'NotAllowedError') {
|
||||
screenShareEndAlert();
|
||||
}
|
||||
};
|
||||
|
||||
const screenshareLocked = screenshareDataSavingSetting
|
||||
? intlMessages.desktopShareLabel : intlMessages.lockedDesktopShareLabel;
|
||||
|
||||
const vLabel = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareLabel : screenshareLocked;
|
||||
|
||||
const vDescr = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
|
||||
|
||||
const shouldAllowScreensharing = screenSharingCheck
|
||||
&& !isMobileBrowser
|
||||
&& amIPresenter;
|
||||
|
||||
return shouldAllowScreensharing
|
||||
? (
|
||||
<Button
|
||||
className={cx(isVideoBroadcasting || styles.btn)}
|
||||
disabled={(!isMeteorConnected && !isVideoBroadcasting) || !screenshareDataSavingSetting}
|
||||
icon={isVideoBroadcasting ? 'desktop' : 'desktop_off'}
|
||||
label={intl.formatMessage(vLabel)}
|
||||
description={intl.formatMessage(vDescr)}
|
||||
color={isVideoBroadcasting ? 'primary' : 'default'}
|
||||
ghost={!isVideoBroadcasting}
|
||||
hideLabel
|
||||
circle
|
||||
size="lg"
|
||||
onClick={isVideoBroadcasting ? handleUnshareScreen : () => {
|
||||
if (IS_SAFARI && !ScreenshareBridgeService.hasDisplayMedia) {
|
||||
return mountModal(<Modal
|
||||
overlayClassName={styles.overlay}
|
||||
className={styles.modal}
|
||||
onRequestClose={() => mountModal(null)}
|
||||
hideBorder
|
||||
contentLabel={intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
>
|
||||
<h3 className={styles.title}>
|
||||
{intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
</h3>
|
||||
<p>{intl.formatMessage(intlMessages.screenShareNotSupported)}</p>
|
||||
</Modal>);
|
||||
}
|
||||
handleShareScreen(onFail);
|
||||
}
|
||||
}
|
||||
id={isVideoBroadcasting ? 'unshare-screen-button' : 'share-screen-button'}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
DesktopShare.propTypes = propTypes;
|
||||
export default withModalMounter(injectIntl(memo(DesktopShare)));
|
209
bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx
Executable file
209
bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx
Executable file
@ -0,0 +1,209 @@
|
||||
import React, { memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import browser from 'browser-detect';
|
||||
import Button from '/imports/ui/components/button/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { notify } from '/imports/ui/services/notification';
|
||||
import cx from 'classnames';
|
||||
import Modal from '/imports/ui/components/modal/simple/component';
|
||||
import { withModalMounter } from '../../modal/service';
|
||||
import { styles } from '../styles';
|
||||
import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
|
||||
import {
|
||||
shareScreen,
|
||||
stop,
|
||||
screenshareHasEnded,
|
||||
screenShareEndAlert,
|
||||
isVideoBroadcasting,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors';
|
||||
|
||||
const BROWSER_RESULTS = browser();
|
||||
const isMobileBrowser = (BROWSER_RESULTS ? BROWSER_RESULTS.mobile : false)
|
||||
|| (BROWSER_RESULTS && BROWSER_RESULTS.os
|
||||
? BROWSER_RESULTS.os.includes('Android') // mobile flag doesn't always work
|
||||
: false);
|
||||
const IS_SAFARI = BROWSER_RESULTS.name === 'safari';
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.objectOf(Object).isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
amIPresenter: PropTypes.bool.isRequired,
|
||||
isVideoBroadcasting: PropTypes.bool.isRequired,
|
||||
isMeteorConnected: PropTypes.bool.isRequired,
|
||||
screenshareDataSavingSetting: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
desktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
|
||||
description: 'Desktop Share option label',
|
||||
},
|
||||
lockedDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.lockedDesktopShareLabel',
|
||||
description: 'Desktop locked Share option label',
|
||||
},
|
||||
stopDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareLabel',
|
||||
description: 'Stop Desktop Share option label',
|
||||
},
|
||||
desktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
|
||||
description: 'adds context to desktop share option',
|
||||
},
|
||||
stopDesktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
|
||||
description: 'adds context to stop desktop share option',
|
||||
},
|
||||
screenShareNotSupported: {
|
||||
id: 'app.media.screenshare.notSupported',
|
||||
descriptions: 'error message when trying share screen on unsupported browsers',
|
||||
},
|
||||
screenShareUnavailable: {
|
||||
id: 'app.media.screenshare.unavailable',
|
||||
descriptions: 'title for unavailable screen share modal',
|
||||
},
|
||||
finalError: {
|
||||
id: 'app.screenshare.screenshareFinalError',
|
||||
description: 'Screen sharing failures with no recovery procedure',
|
||||
},
|
||||
retryError: {
|
||||
id: 'app.screenshare.screenshareRetryError',
|
||||
description: 'Screen sharing failures where a retry is recommended',
|
||||
},
|
||||
retryOtherEnvError: {
|
||||
id: 'app.screenshare.screenshareRetryOtherEnvError',
|
||||
description: 'Screen sharing failures where a retry in another environment is recommended',
|
||||
},
|
||||
unsupportedEnvError: {
|
||||
id: 'app.screenshare.screenshareUnsupportedEnv',
|
||||
description: 'Screen sharing is not supported, changing browser or device is recommended',
|
||||
},
|
||||
permissionError: {
|
||||
id: 'app.screenshare.screensharePermissionError',
|
||||
description: 'Screen sharing failure due to lack of permission',
|
||||
},
|
||||
});
|
||||
|
||||
const getErrorLocale = (errorCode) => {
|
||||
switch (errorCode) {
|
||||
// Denied getDisplayMedia permission error
|
||||
case SCREENSHARING_ERRORS.NotAllowedError.errorCode:
|
||||
return intlMessages.permissionError;
|
||||
// Browser is supposed to be supported, but a browser-related error happening.
|
||||
// Suggest retrying in another device/browser/env
|
||||
case SCREENSHARING_ERRORS.AbortError.errorCode:
|
||||
case SCREENSHARING_ERRORS.InvalidStateError.errorCode:
|
||||
case SCREENSHARING_ERRORS.OverconstrainedError.errorCode:
|
||||
case SCREENSHARING_ERRORS.TypeError.errorCode:
|
||||
case SCREENSHARING_ERRORS.NotFoundError.errorCode:
|
||||
case SCREENSHARING_ERRORS.NotReadableError.errorCode:
|
||||
case SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_NO_AVAILABLE_CODEC.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_INVALID_SDP.errorCode:
|
||||
return intlMessages.retryOtherEnvError;
|
||||
// Fatal errors where a retry isn't warranted. This probably means the server
|
||||
// is misconfigured somehow or the provider is utterly botched, so nothing
|
||||
// the end user can do besides requesting support
|
||||
case SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_SERVER_CONNECTION_ERROR.errorCode:
|
||||
case SCREENSHARING_ERRORS.SFU_INVALID_REQUEST.errorCode:
|
||||
return intlMessages.finalError;
|
||||
// Unsupported errors
|
||||
case SCREENSHARING_ERRORS.NotSupportedError.errorCode:
|
||||
return intlMessages.unsupportedEnvError;
|
||||
// Fall through: everything else is an error which might be solved with a retry
|
||||
default:
|
||||
return intlMessages.retryError;
|
||||
}
|
||||
}
|
||||
|
||||
const ScreenshareButton = ({
|
||||
intl,
|
||||
enabled,
|
||||
isVideoBroadcasting,
|
||||
amIPresenter,
|
||||
isMeteorConnected,
|
||||
screenshareDataSavingSetting,
|
||||
mountModal,
|
||||
}) => {
|
||||
// This is the failure callback that will be passed to the /api/screenshare/kurento.js
|
||||
// script on the presenter's call
|
||||
const handleFailure = (error) => {
|
||||
const {
|
||||
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
|
||||
errorMessage
|
||||
} = error;
|
||||
|
||||
logger.error({
|
||||
logCode: 'screenshare_failed',
|
||||
extraInfo: { errorCode, errorMessage },
|
||||
}, 'Screenshare failed');
|
||||
|
||||
const localizedError = getErrorLocale(errorCode);
|
||||
notify(intl.formatMessage(localizedError, { 0: errorCode }), 'error', 'desktop');
|
||||
screenshareHasEnded();
|
||||
};
|
||||
|
||||
const renderScreenshareUnavailableModal = () => {
|
||||
return mountModal(
|
||||
<Modal
|
||||
overlayClassName={styles.overlay}
|
||||
className={styles.modal}
|
||||
onRequestClose={() => mountModal(null)}
|
||||
hideBorder
|
||||
contentLabel={intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
>
|
||||
<h3 className={styles.title}>
|
||||
{intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
</h3>
|
||||
<p>{intl.formatMessage(intlMessages.screenShareNotSupported)}</p>
|
||||
</Modal>
|
||||
)
|
||||
};
|
||||
|
||||
const screenshareLocked = screenshareDataSavingSetting
|
||||
? intlMessages.desktopShareLabel : intlMessages.lockedDesktopShareLabel;
|
||||
|
||||
const vLabel = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareLabel : screenshareLocked;
|
||||
|
||||
const vDescr = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
|
||||
|
||||
const shouldAllowScreensharing = enabled
|
||||
&& !isMobileBrowser
|
||||
&& amIPresenter;
|
||||
|
||||
return shouldAllowScreensharing
|
||||
? (
|
||||
<Button
|
||||
className={cx(isVideoBroadcasting || styles.btn)}
|
||||
disabled={(!isMeteorConnected && !isVideoBroadcasting) || !screenshareDataSavingSetting}
|
||||
icon={isVideoBroadcasting ? 'desktop' : 'desktop_off'}
|
||||
label={intl.formatMessage(vLabel)}
|
||||
description={intl.formatMessage(vDescr)}
|
||||
color={isVideoBroadcasting ? 'primary' : 'default'}
|
||||
ghost={!isVideoBroadcasting}
|
||||
hideLabel
|
||||
circle
|
||||
size="lg"
|
||||
onClick={isVideoBroadcasting
|
||||
? screenshareHasEnded
|
||||
: () => {
|
||||
if (IS_SAFARI && !ScreenshareBridgeService.HAS_DISPLAY_MEDIA) {
|
||||
renderScreenshareUnavailableModal();
|
||||
} else {
|
||||
shareScreen(handleFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
id={isVideoBroadcasting ? 'unshare-screen-button' : 'share-screen-button'}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
ScreenshareButton.propTypes = propTypes;
|
||||
export default withModalMounter(injectIntl(memo(ScreenshareButton)));
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { withTracker } from 'meteor/react-meteor-data';
|
||||
import { withModalMounter } from '/imports/ui/components/modal/service';
|
||||
import ScreenshareButton from './component';
|
||||
import getFromUserSettings from '/imports/ui/services/users-settings';
|
||||
import {
|
||||
isVideoBroadcasting,
|
||||
dataSavingSetting,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
|
||||
const ScreenshareButtonContainer = props => <ScreenshareButton {...props} />;
|
||||
|
||||
/*
|
||||
* All props, including the ones that are inherited from actions-bar
|
||||
* isVideoBroadcasting,
|
||||
* amIPresenter,
|
||||
* screenSharingCheck,
|
||||
* isMeteorConnected,
|
||||
* screenshareDataSavingSetting,
|
||||
*/
|
||||
export default withModalMounter(withTracker(({ mountModal }) => ({
|
||||
isVideoBroadcasting: isVideoBroadcasting(),
|
||||
screenshareDataSavingSetting: dataSavingSetting(),
|
||||
enabled: getFromUserSettings(
|
||||
'bbb_enable_screen_sharing',
|
||||
Meteor.settings.public.kurento.enableScreensharing
|
||||
),
|
||||
}))(ScreenshareButtonContainer));
|
@ -7,9 +7,22 @@ import FullscreenButtonContainer from '../fullscreen-button/container';
|
||||
import { styles } from './styles';
|
||||
import AutoplayOverlay from '../media/autoplay-overlay/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import cx from 'classnames';
|
||||
import playAndRetry from '/imports/utils/mediaElementPlayRetry';
|
||||
import PollingContainer from '/imports/ui/components/polling/container';
|
||||
import { withLayoutConsumer } from '/imports/ui/components/layout/context';
|
||||
import {
|
||||
SCREENSHARE_MEDIA_ELEMENT_NAME,
|
||||
screenshareHasEnded,
|
||||
screenshareHasStarted,
|
||||
getMediaElement,
|
||||
attachLocalPreviewStream,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
import {
|
||||
isStreamStateUnhealthy,
|
||||
subscribeToStreamStateChange,
|
||||
unsubscribeFromStreamStateChange,
|
||||
} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
screenShareLabel: {
|
||||
@ -33,49 +46,63 @@ class ScreenshareComponent extends React.Component {
|
||||
loaded: false,
|
||||
isFullscreen: false,
|
||||
autoplayBlocked: false,
|
||||
isStreamHealthy: false,
|
||||
};
|
||||
|
||||
this.onVideoLoad = this.onVideoLoad.bind(this);
|
||||
this.onLoadedData = this.onLoadedData.bind(this);
|
||||
this.onFullscreenChange = this.onFullscreenChange.bind(this);
|
||||
this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
|
||||
this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
|
||||
this.failedMediaElements = [];
|
||||
this.onStreamStateChange = this.onStreamStateChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { presenterScreenshareHasStarted } = this.props;
|
||||
presenterScreenshareHasStarted();
|
||||
|
||||
screenshareHasStarted();
|
||||
this.screenshareContainer.addEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
// Autoplay failure handling
|
||||
window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed);
|
||||
// Stream health state tracker to propagate UI changes on reconnections
|
||||
subscribeToStreamStateChange('screenshare', this.onStreamStateChange);
|
||||
// Attaches the local stream if it exists to serve as the local presenter preview
|
||||
attachLocalPreviewStream(getMediaElement());
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isPresenter, unshareScreen,
|
||||
isPresenter,
|
||||
} = this.props;
|
||||
if (isPresenter && !prevProps.isPresenter) {
|
||||
unshareScreen();
|
||||
screenshareHasEnded();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {
|
||||
presenterScreenshareHasEnded,
|
||||
unshareScreen,
|
||||
getSwapLayout,
|
||||
shouldEnableSwapLayout,
|
||||
toggleSwapLayout,
|
||||
} = this.props;
|
||||
const layoutSwapped = getSwapLayout() && shouldEnableSwapLayout();
|
||||
if (layoutSwapped) toggleSwapLayout();
|
||||
presenterScreenshareHasEnded();
|
||||
unshareScreen();
|
||||
screenshareHasEnded();
|
||||
this.screenshareContainer.removeEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
|
||||
unsubscribeFromStreamStateChange('screenshare', this.onStreamStateChange);
|
||||
}
|
||||
|
||||
onVideoLoad() {
|
||||
onStreamStateChange (event) {
|
||||
const { streamState } = event.detail;
|
||||
const { isStreamHealthy } = this.state;
|
||||
|
||||
const newHealthState = !isStreamStateUnhealthy(streamState);
|
||||
event.stopPropagation();
|
||||
if (newHealthState !== isStreamHealthy) {
|
||||
this.setState({ isStreamHealthy: newHealthState });
|
||||
}
|
||||
}
|
||||
|
||||
onLoadedData() {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
@ -147,12 +174,35 @@ class ScreenshareComponent extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loaded, autoplayBlocked, isFullscreen } = this.state;
|
||||
renderAutoplayOverlay() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
[!loaded
|
||||
<AutoplayOverlay
|
||||
key={_.uniqueId('screenshareAutoplayOverlay')}
|
||||
autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)}
|
||||
autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)}
|
||||
handleAllowAutoplay={this.handleAllowAutoplay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loaded, autoplayBlocked, isFullscreen, isStreamHealthy } = this.state;
|
||||
const { intl, isPresenter, isGloballyBroadcasting } = this.props;
|
||||
|
||||
// Conditions to render the (re)connecting spinner and the unhealthy stream
|
||||
// grayscale:
|
||||
// 1 - The local media tag has not received any stream data yet
|
||||
// 2 - The user is a presenter and the stream wasn't globally broadcasted yet
|
||||
// 3 - The media was loaded, the stream was globally broadcasted BUT the stream
|
||||
// state transitioned to an unhealthy stream. tl;dr: screen sharing reconnection
|
||||
const shouldRenderConnectingState = !loaded
|
||||
|| (isPresenter && !isGloballyBroadcasting)
|
||||
|| !isStreamHealthy && loaded && isGloballyBroadcasting;
|
||||
|
||||
return (
|
||||
[(shouldRenderConnectingState)
|
||||
? (
|
||||
<div
|
||||
key={_.uniqueId('screenshareArea-')}
|
||||
@ -163,29 +213,28 @@ class ScreenshareComponent extends React.Component {
|
||||
: null,
|
||||
!autoplayBlocked
|
||||
? null
|
||||
: (
|
||||
<AutoplayOverlay
|
||||
key={_.uniqueId('screenshareAutoplayOverlay')}
|
||||
autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)}
|
||||
autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)}
|
||||
handleAllowAutoplay={this.handleAllowAutoplay}
|
||||
/>
|
||||
),
|
||||
: (this.renderAutoplayOverlay()),
|
||||
(
|
||||
<div
|
||||
className={styles.screenshareContainer}
|
||||
key="screenshareContainer"
|
||||
ref={(ref) => { this.screenshareContainer = ref; }}
|
||||
>
|
||||
|
||||
{isFullscreen && <PollingContainer />}
|
||||
|
||||
{loaded && this.renderFullscreenButton()}
|
||||
|
||||
<video
|
||||
id="screenshareVideo"
|
||||
key="screenshareVideo"
|
||||
id={SCREENSHARE_MEDIA_ELEMENT_NAME}
|
||||
key={SCREENSHARE_MEDIA_ELEMENT_NAME}
|
||||
style={{ maxHeight: '100%', width: '100%', height: '100%' }}
|
||||
playsInline
|
||||
onLoadedData={this.onVideoLoad}
|
||||
onLoadedData={this.onLoadedData}
|
||||
ref={(ref) => { this.videoTag = ref; }}
|
||||
className={cx({
|
||||
[styles.unhealthyStream]: shouldRenderConnectingState,
|
||||
})}
|
||||
muted
|
||||
/>
|
||||
</div>
|
||||
@ -199,7 +248,4 @@ export default injectIntl(withLayoutConsumer(ScreenshareComponent));
|
||||
ScreenshareComponent.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
isPresenter: PropTypes.bool.isRequired,
|
||||
unshareScreen: PropTypes.func.isRequired,
|
||||
presenterScreenshareHasEnded: PropTypes.func.isRequired,
|
||||
presenterScreenshareHasStarted: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -4,14 +4,13 @@ import Users from '/imports/api/users/';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import MediaService, { getSwapLayout, shouldEnableSwapLayout } from '/imports/ui/components/media/service';
|
||||
import {
|
||||
isVideoBroadcasting, presenterScreenshareHasEnded, unshareScreen,
|
||||
presenterScreenshareHasStarted,
|
||||
isVideoBroadcasting,
|
||||
isGloballyBroadcasting,
|
||||
} from './service';
|
||||
import ScreenshareComponent from './component';
|
||||
|
||||
const ScreenshareContainer = (props) => {
|
||||
const { isVideoBroadcasting: isVB } = props;
|
||||
if (isVB()) {
|
||||
if (isVideoBroadcasting()) {
|
||||
return <ScreenshareComponent {...props} />;
|
||||
}
|
||||
return null;
|
||||
@ -20,11 +19,8 @@ const ScreenshareContainer = (props) => {
|
||||
export default withTracker(() => {
|
||||
const user = Users.findOne({ userId: Auth.userID }, { fields: { presenter: 1 } });
|
||||
return {
|
||||
isGloballyBroadcasting: isGloballyBroadcasting(),
|
||||
isPresenter: user.presenter,
|
||||
unshareScreen,
|
||||
isVideoBroadcasting,
|
||||
presenterScreenshareHasStarted,
|
||||
presenterScreenshareHasEnded,
|
||||
getSwapLayout,
|
||||
shouldEnableSwapLayout,
|
||||
toggleSwapLayout: MediaService.toggleSwapLayout,
|
||||
|
@ -11,65 +11,124 @@ import UserListService from '/imports/ui/components/user-list/service';
|
||||
import AudioService from '/imports/ui/components/audio/service';
|
||||
import {Meteor} from "meteor/meteor";
|
||||
|
||||
const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo';
|
||||
|
||||
let _isSharingScreen = false;
|
||||
const _sharingScreenDep = {
|
||||
value: false,
|
||||
tracker: new Tracker.Dependency(),
|
||||
};
|
||||
|
||||
const isSharingScreen = () => {
|
||||
_sharingScreenDep.tracker.depend();
|
||||
return _sharingScreenDep.value;
|
||||
};
|
||||
|
||||
const setSharingScreen = (isSharingScreen) => {
|
||||
if (_sharingScreenDep.value !== isSharingScreen) {
|
||||
_sharingScreenDep.value = isSharingScreen;
|
||||
_sharingScreenDep.tracker.changed();
|
||||
}
|
||||
};
|
||||
|
||||
// A simplified, trackable version of isVideoBroadcasting that DOES NOT
|
||||
// account for the presenter's local sharing state.
|
||||
// It reflects the GLOBAL screen sharing state (akka-apps)
|
||||
const isGloballyBroadcasting = () => {
|
||||
const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'screenshare.stream': 1 } });
|
||||
|
||||
return (!screenshareEntry ? false : !!screenshareEntry.screenshare.stream);
|
||||
}
|
||||
|
||||
// when the meeting information has been updated check to see if it was
|
||||
// screensharing. If it has changed either trigger a call to receive video
|
||||
// and display it, or end the call and hide the video
|
||||
const isVideoBroadcasting = () => {
|
||||
const sharing = isSharingScreen();
|
||||
const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'screenshare.stream': 1 } });
|
||||
const screenIsShared = !screenshareEntry ? false : !!screenshareEntry.screenshare.stream;
|
||||
|
||||
if (screenIsShared && isSharingScreen) {
|
||||
setSharingScreen(false);
|
||||
}
|
||||
|
||||
return sharing || screenIsShared;
|
||||
};
|
||||
|
||||
|
||||
const screenshareHasAudio = () => {
|
||||
const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID },
|
||||
{ fields: { 'screenshare.hasAudio': 1 } });
|
||||
|
||||
if (!screenshareEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!screenshareEntry.screenshare.stream;
|
||||
};
|
||||
return !!screenshareEntry.screenshare.hasAudio;
|
||||
}
|
||||
|
||||
// if remote screenshare has been ended disconnect and hide the video stream
|
||||
const presenterScreenshareHasEnded = () => {
|
||||
// references a function in the global namespace inside kurento-extension.js
|
||||
// that we load dynamically
|
||||
KurentoBridge.kurentoExitVideo();
|
||||
};
|
||||
|
||||
const viewScreenshare = () => {
|
||||
const screenshareHasEnded = () => {
|
||||
const amIPresenter = UserListService.isUserPresenter(Auth.userID);
|
||||
|
||||
if (amIPresenter) {
|
||||
setSharingScreen(false);
|
||||
}
|
||||
|
||||
KurentoBridge.stop();
|
||||
screenShareEndAlert();
|
||||
};
|
||||
|
||||
const getMediaElement = () => {
|
||||
return document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME);
|
||||
}
|
||||
|
||||
const attachLocalPreviewStream = (mediaElement) => {
|
||||
const stream = KurentoBridge.gdmStream;
|
||||
if (stream && mediaElement) {
|
||||
// Always muted, presenter preview.
|
||||
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, true);
|
||||
}
|
||||
}
|
||||
|
||||
const screenshareHasStarted = () => {
|
||||
const amIPresenter = UserListService.isUserPresenter(Auth.userID);
|
||||
|
||||
// Presenter's screen preview is local, so skip
|
||||
if (!amIPresenter) {
|
||||
KurentoBridge.kurentoViewScreen();
|
||||
} else {
|
||||
KurentoBridge.kurentoViewLocalPreview();
|
||||
viewScreenshare();
|
||||
}
|
||||
};
|
||||
|
||||
// if remote screenshare has been started connect and display the video stream
|
||||
const presenterScreenshareHasStarted = () => {
|
||||
// WebRTC restrictions may need a capture device permission to release
|
||||
// useful ICE candidates on recvonly/no-gUM peers
|
||||
tryGenerateIceCandidates().then(() => {
|
||||
viewScreenshare();
|
||||
}).catch((error) => {
|
||||
logger.error({
|
||||
logCode: 'screenshare_no_valid_candidate_gum_failure',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, `Forced gUM to release additional ICE candidates failed due to ${error.name}.`);
|
||||
// The fallback gUM failed. Try it anyways and hope for the best.
|
||||
viewScreenshare();
|
||||
});
|
||||
};
|
||||
|
||||
const shareScreen = (onFail) => {
|
||||
const shareScreen = async (onFail) => {
|
||||
// stop external video share if running
|
||||
const meeting = Meetings.findOne({ meetingId: Auth.meetingID });
|
||||
|
||||
if (meeting && meeting.externalVideoUrl) {
|
||||
stopWatching();
|
||||
}
|
||||
|
||||
BridgeService.getScreenStream().then((stream) => {
|
||||
KurentoBridge.kurentoShareScreen(onFail, stream);
|
||||
}).catch(onFail);
|
||||
try {
|
||||
const stream = await BridgeService.getScreenStream();
|
||||
await KurentoBridge.share(stream, onFail);
|
||||
setSharingScreen(true);
|
||||
} catch (error) {
|
||||
return onFail(error);
|
||||
}
|
||||
};
|
||||
|
||||
const viewScreenshare = () => {
|
||||
const hasAudio = screenshareHasAudio();
|
||||
KurentoBridge.view(hasAudio).catch((error) => {
|
||||
logger.error({
|
||||
logCode: 'screenshare_view_failed',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, `Screenshare viewer failure`);
|
||||
});
|
||||
};
|
||||
|
||||
const screenShareEndAlert = () => AudioService
|
||||
@ -78,19 +137,19 @@ const screenShareEndAlert = () => AudioService
|
||||
+ Meteor.settings.public.app.instanceId}`
|
||||
+ '/resources/sounds/ScreenshareOff.mp3');
|
||||
|
||||
const unshareScreen = () => {
|
||||
KurentoBridge.kurentoExitScreenShare();
|
||||
screenShareEndAlert();
|
||||
};
|
||||
|
||||
const dataSavingSetting = () => Settings.dataSaving.viewScreenshare;
|
||||
|
||||
export {
|
||||
SCREENSHARE_MEDIA_ELEMENT_NAME,
|
||||
isVideoBroadcasting,
|
||||
presenterScreenshareHasEnded,
|
||||
presenterScreenshareHasStarted,
|
||||
screenshareHasEnded,
|
||||
screenshareHasStarted,
|
||||
shareScreen,
|
||||
screenShareEndAlert,
|
||||
unshareScreen,
|
||||
dataSavingSetting,
|
||||
isSharingScreen,
|
||||
setSharingScreen,
|
||||
getMediaElement,
|
||||
attachLocalPreviewStream,
|
||||
isGloballyBroadcasting,
|
||||
};
|
||||
|
@ -2,10 +2,9 @@
|
||||
|
||||
.connecting {
|
||||
@extend .connectingSpinner;
|
||||
z-index: -1;
|
||||
background-color: transparent;
|
||||
color: var(--color-white);
|
||||
font-size: 2.5rem * 5;
|
||||
font-size: 2.5rem * 3;
|
||||
}
|
||||
|
||||
.screenshareContainer{
|
||||
@ -17,3 +16,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.unhealthyStream {
|
||||
filter: grayscale(50%) opacity(50%);
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
export default SFU_BROKER_ERRORS = {
|
||||
const SFU_CLIENT_SIDE_ERRORS = {
|
||||
// 13xx errors are client-side bbb-webrtc-sfu's base broker errors
|
||||
1301: "WEBSOCKET_DISCONNECTED",
|
||||
1302: "WEBSOCKET_CONNECTION_FAILED",
|
||||
1305: "PEER_NEGOTIATION_FAILED",
|
||||
1307: "ICE_STATE_FAILED",
|
||||
};
|
||||
|
||||
const SFU_SERVER_SIDE_ERRORS = {
|
||||
// 2xxx codes are server-side bbb-webrtc-sfu errors
|
||||
2000: "MEDIA_SERVER_CONNECTION_ERROR",
|
||||
2001: "MEDIA_SERVER_OFFLINE",
|
||||
@ -22,4 +25,12 @@ export default SFU_BROKER_ERRORS = {
|
||||
2210: "MEDIA_CONNECT_ERROR",
|
||||
2211: "MEDIA_NOT_FLOWING",
|
||||
2300: "SFU_INVALID_REQUEST",
|
||||
}
|
||||
};
|
||||
|
||||
const SFU_BROKER_ERRORS = { ...SFU_SERVER_SIDE_ERRORS, ...SFU_CLIENT_SIDE_ERRORS };
|
||||
|
||||
export {
|
||||
SFU_CLIENT_SIDE_ERRORS,
|
||||
SFU_SERVER_SIDE_ERRORS,
|
||||
SFU_BROKER_ERRORS,
|
||||
};
|
||||
|
@ -0,0 +1,230 @@
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import BaseBroker from '/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker';
|
||||
|
||||
const ON_ICE_CANDIDATE_MSG = 'iceCandidate';
|
||||
const SFU_COMPONENT_NAME = 'screenshare';
|
||||
|
||||
class ScreenshareBroker extends BaseBroker {
|
||||
constructor(
|
||||
wsUrl,
|
||||
voiceBridge,
|
||||
userId,
|
||||
internalMeetingId,
|
||||
role,
|
||||
options = {},
|
||||
) {
|
||||
super(SFU_COMPONENT_NAME, wsUrl);
|
||||
this.voiceBridge = voiceBridge;
|
||||
this.userId = userId;
|
||||
this.internalMeetingId = internalMeetingId;
|
||||
this.role = role;
|
||||
this.ws = null;
|
||||
this.webRtcPeer = null;
|
||||
this.hasAudio = false;
|
||||
|
||||
// Optional parameters are: userName, caleeName, iceServers, hasAudio
|
||||
Object.assign(this, options);
|
||||
}
|
||||
|
||||
onstreamended () {
|
||||
// To be implemented by instantiators
|
||||
}
|
||||
|
||||
share () {
|
||||
return this.openWSConnection()
|
||||
.then(this.startScreensharing.bind(this));
|
||||
}
|
||||
|
||||
view () {
|
||||
return this.openWSConnection()
|
||||
.then(this.subscribeToScreenStream.bind(this));
|
||||
}
|
||||
|
||||
onWSMessage (message) {
|
||||
const parsedMessage = JSON.parse(message.data);
|
||||
|
||||
switch (parsedMessage.id) {
|
||||
case 'startResponse':
|
||||
this.processAnswer(parsedMessage);
|
||||
break;
|
||||
case 'playStart':
|
||||
this.onstart();
|
||||
this.started = true;
|
||||
break;
|
||||
case 'stopSharing':
|
||||
this.stop();
|
||||
break;
|
||||
case 'iceCandidate':
|
||||
this.handleIceCandidate(parsedMessage.candidate);
|
||||
break;
|
||||
case 'error':
|
||||
this.handleSFUError(parsedMessage);
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
default:
|
||||
logger.debug({
|
||||
logCode: `${this.logCodePrefix}_invalid_req`,
|
||||
extraInfo: {
|
||||
messageId: parsedMessage.id || 'Unknown',
|
||||
sfuComponent: this.sfuComponent,
|
||||
role: this.role,
|
||||
}
|
||||
}, `Discarded invalid SFU message`);
|
||||
}
|
||||
}
|
||||
|
||||
handleSFUError (sfuResponse) {
|
||||
const { code, reason } = sfuResponse;
|
||||
const error = BaseBroker.assembleError(code, reason);
|
||||
|
||||
logger.error({
|
||||
logCode: `${this.logCodePrefix}_sfu_error`,
|
||||
extraInfo: {
|
||||
errorCode: code,
|
||||
errorMessage: error.errorMessage,
|
||||
role: this.role,
|
||||
sfuComponent: this.sfuComponent,
|
||||
started: this.started,
|
||||
},
|
||||
}, `Screen sharing failed in SFU`);
|
||||
this.onerror(error);
|
||||
}
|
||||
|
||||
onOfferGenerated (error, sdpOffer) {
|
||||
if (error) {
|
||||
logger.error({
|
||||
logCode: `${this.logCodePrefix}_offer_failure`,
|
||||
extraInfo: {
|
||||
errorMessage: error.name || error.message || 'Unknown error',
|
||||
role: this.role,
|
||||
sfuComponent: this.sfuComponent
|
||||
},
|
||||
}, `Screenshare offer generation failed`);
|
||||
// 1305: "PEER_NEGOTIATION_FAILED",
|
||||
const normalizedError = BaseBroker.assembleError(1305);
|
||||
return this.onerror(error);
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: 'start',
|
||||
type: this.sfuComponent,
|
||||
role: this.role,
|
||||
internalMeetingId: this.internalMeetingId,
|
||||
voiceBridge: this.voiceBridge,
|
||||
userName: this.userName,
|
||||
callerName: this.userId,
|
||||
sdpOffer,
|
||||
hasAudio: !!this.hasAudio,
|
||||
};
|
||||
|
||||
this.sendMessage(message);
|
||||
}
|
||||
|
||||
startScreensharing () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
onicecandidate: (candidate) => {
|
||||
this.onIceCandidate(candidate, this.role);
|
||||
},
|
||||
videoStream: this.stream,
|
||||
};
|
||||
|
||||
this.addIceServers(options);
|
||||
this.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerSendonly(options, (error) => {
|
||||
if (error) {
|
||||
// 1305: "PEER_NEGOTIATION_FAILED",
|
||||
const normalizedError = BaseBroker.assembleError(1305);
|
||||
logger.error({
|
||||
logCode: `${this.logCodePrefix}_peer_creation_failed`,
|
||||
extraInfo: {
|
||||
errorMessage: error.name || error.message || 'Unknown error',
|
||||
errorCode: normalizedError.errorCode,
|
||||
role: this.role,
|
||||
sfuComponent: this.sfuComponent,
|
||||
started: this.started,
|
||||
},
|
||||
}, `Screenshare peer creation failed`);
|
||||
this.onerror(normalizedError);
|
||||
return reject(normalizedError);
|
||||
}
|
||||
|
||||
this.webRtcPeer.iceQueue = [];
|
||||
this.webRtcPeer.generateOffer(this.onOfferGenerated.bind(this));
|
||||
|
||||
const localStream = this.webRtcPeer.peerConnection.getLocalStreams()[0];
|
||||
|
||||
localStream.getVideoTracks()[0].onended = () => {
|
||||
this.webRtcPeer.peerConnection.onconnectionstatechange = null;
|
||||
this.onstreamended();
|
||||
};
|
||||
|
||||
localStream.getVideoTracks()[0].oninactive = () => {
|
||||
this.onstreamended();
|
||||
};
|
||||
|
||||
return resolve();
|
||||
});
|
||||
|
||||
this.webRtcPeer.peerConnection.onconnectionstatechange = () => {
|
||||
this.handleConnectionStateChange('screenshare');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onIceCandidate (candidate, role) {
|
||||
const message = {
|
||||
id: ON_ICE_CANDIDATE_MSG,
|
||||
role,
|
||||
type: this.sfuComponent,
|
||||
voiceBridge: this.voiceBridge,
|
||||
candidate,
|
||||
callerName: this.userId,
|
||||
};
|
||||
|
||||
this.sendMessage(message);
|
||||
}
|
||||
|
||||
subscribeToScreenStream () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
mediaConstraints: {
|
||||
audio: !!this.hasAudio,
|
||||
},
|
||||
onicecandidate: (candidate) => {
|
||||
this.onIceCandidate(candidate, this.role);
|
||||
},
|
||||
};
|
||||
|
||||
this.addIceServers(options);
|
||||
|
||||
this.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly(options, (error) => {
|
||||
if (error) {
|
||||
// 1305: "PEER_NEGOTIATION_FAILED",
|
||||
const normalizedError = BaseBroker.assembleError(1305);
|
||||
logger.error({
|
||||
logCode: `${this.logCodePrefix}_peer_creation_failed`,
|
||||
extraInfo: {
|
||||
errorMessage: error.name || error.message || 'Unknown error',
|
||||
errorCode: normalizedError.errorCode,
|
||||
role: this.role,
|
||||
sfuComponent: this.sfuComponent,
|
||||
started: this.started,
|
||||
},
|
||||
}, `Screenshare peer creation failed`);
|
||||
this.onerror(normalizedError);
|
||||
return reject(normalizedError);
|
||||
}
|
||||
this.webRtcPeer.iceQueue = [];
|
||||
this.webRtcPeer.generateOffer(this.onOfferGenerated.bind(this));
|
||||
});
|
||||
|
||||
this.webRtcPeer.peerConnection.onconnectionstatechange = () => {
|
||||
this.handleConnectionStateChange('screenshare');
|
||||
};
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ScreenshareBroker;
|
@ -1,6 +1,6 @@
|
||||
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';
|
||||
import { SFU_BROKER_ERRORS } from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
|
||||
|
||||
const PING_INTERVAL_MS = 15000;
|
||||
|
||||
|
@ -37,7 +37,7 @@ export const unsubscribeFromStreamStateChange = (eventTag, callback) => {
|
||||
}
|
||||
|
||||
export const isStreamStateUnhealthy = (streamState) => {
|
||||
return streamState === 'disconnected' || streamState === 'failed' || streamState === 'closed';
|
||||
return streamState === 'failed' || streamState === 'closed';
|
||||
}
|
||||
|
||||
export const isStreamStateHealthy = (streamState) => {
|
||||
|
@ -146,11 +146,15 @@ public:
|
||||
# Max timeout: used as the max camera subscribe reconnection timeout. Each
|
||||
# subscribe reattempt increases the reconnection timer up to this
|
||||
maxTimeout: 60000
|
||||
chromeDefaultExtensionKey: akgoaoikmbmhcopjgakkcepdgdgkjfbc
|
||||
chromeDefaultExtensionLink: https://chrome.google.com/webstore/detail/bigbluebutton-screenshare/akgoaoikmbmhcopjgakkcepdgdgkjfbc
|
||||
chromeExtensionKey: KEY
|
||||
chromeExtensionLink: LINK
|
||||
screenshare:
|
||||
mediaTimeouts:
|
||||
maxConnectionAttempts: 2
|
||||
# Base screen media timeout (send|recv)
|
||||
baseTimeout: 15000
|
||||
# Max timeout: used as the max camera subscribe reconnection timeout. Each
|
||||
# subscribe reattempt increases the reconnection timer up to this
|
||||
maxTimeout: 35000
|
||||
timeoutIncreaseFactor: 1.5
|
||||
constraints:
|
||||
video:
|
||||
frameRate:
|
||||
@ -161,10 +165,6 @@ public:
|
||||
height:
|
||||
max: 1600
|
||||
audio: false
|
||||
chromeScreenshareSources:
|
||||
- window
|
||||
- screen
|
||||
firefoxScreenshareSource: window
|
||||
# cameraProfiles is an array of:
|
||||
# - id: profile identifier
|
||||
# name: human-readable profile name
|
||||
@ -583,7 +583,7 @@ private:
|
||||
- browser: chromeMobileIOS
|
||||
version: Infinity
|
||||
- browser: firefox
|
||||
version: 63
|
||||
version: 68
|
||||
- browser: firefoxMobile
|
||||
version: 68
|
||||
- browser: edge
|
||||
|
@ -1,923 +0,0 @@
|
||||
const isFirefox = typeof window.InstallTrigger !== 'undefined';
|
||||
const isOpera = !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0;
|
||||
const isChrome = !!window.chrome && !isOpera;
|
||||
const isSafari = navigator.userAgent.indexOf('Safari') >= 0 && !isChrome;
|
||||
const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1;
|
||||
const hasDisplayMedia = (typeof navigator.getDisplayMedia === 'function'
|
||||
|| (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function'));
|
||||
|
||||
Kurento = function (
|
||||
tag,
|
||||
voiceBridge,
|
||||
userId,
|
||||
internalMeetingId,
|
||||
onFail,
|
||||
onSuccess,
|
||||
options = {},
|
||||
) {
|
||||
this.ws = null;
|
||||
this.video = null;
|
||||
this.screen = null;
|
||||
this.webRtcPeer = null;
|
||||
this.mediaCallback = null;
|
||||
|
||||
this.renderTag = tag;
|
||||
this.voiceBridge = voiceBridge;
|
||||
this.userId = userId;
|
||||
this.internalMeetingId = internalMeetingId;
|
||||
|
||||
// Optional parameters are: userName, caleeName, chromeExtension, wsUrl, iceServers,
|
||||
// chromeScreenshareSources, firefoxScreenshareSource, logger, stream
|
||||
|
||||
Object.assign(this, options);
|
||||
|
||||
this.SEND_ROLE = 'send';
|
||||
this.RECV_ROLE = 'recv';
|
||||
this.SFU_APP = 'screenshare';
|
||||
this.ON_ICE_CANDIDATE_MSG = 'iceCandidate';
|
||||
this.PING_INTERVAL = 15000;
|
||||
|
||||
window.Logger = this.logger || console;
|
||||
|
||||
if (this.wsUrl == null) {
|
||||
this.defaultPath = 'bbb-webrtc-sfu';
|
||||
this.hostName = window.location.hostname;
|
||||
this.wsUrl = `wss://${this.hostName}/${this.defaultPath}`;
|
||||
}
|
||||
|
||||
if (this.chromeScreenshareSources == null) {
|
||||
this.chromeScreenshareSources = ['screen', 'window'];
|
||||
}
|
||||
|
||||
if (this.firefoxScreenshareSource == null) {
|
||||
this.firefoxScreenshareSource = 'window';
|
||||
}
|
||||
|
||||
// Limiting max resolution to WQXGA
|
||||
// In FireFox we force full screen share and in the case
|
||||
// of multiple screens the total area shared becomes too large
|
||||
this.vid_max_width = 2560;
|
||||
this.vid_max_height = 1600;
|
||||
this.width = window.screen.width;
|
||||
this.height = window.screen.height;
|
||||
|
||||
|
||||
this.userId = userId;
|
||||
|
||||
this.pingInterval = null;
|
||||
|
||||
// TODO FIXME we need to implement a handleError method to normalize errors
|
||||
// generated in this script
|
||||
if (onFail != null) {
|
||||
this.onFail = Kurento.normalizeCallback(onFail);
|
||||
} else {
|
||||
const _this = this;
|
||||
this.onFail = function () {
|
||||
_this.logger.error('Default error handler');
|
||||
};
|
||||
}
|
||||
|
||||
if (onSuccess != null) {
|
||||
this.onSuccess = Kurento.normalizeCallback(onSuccess);
|
||||
} else {
|
||||
const _this = this;
|
||||
this.onSuccess = function () {
|
||||
_this.logger.info('Default success handler');
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
this.KurentoManager = function () {
|
||||
this.kurentoVideo = null;
|
||||
this.kurentoScreenshare = null;
|
||||
this.kurentoAudio = null;
|
||||
};
|
||||
|
||||
KurentoManager.prototype.exitScreenShare = function () {
|
||||
if (typeof this.kurentoScreenshare !== 'undefined' && this.kurentoScreenshare) {
|
||||
if (this.kurentoScreenshare.logger !== null) {
|
||||
this.kurentoScreenshare.logger.info({ logCode: 'kurentoextension_exit_screenshare_presenter' },
|
||||
'Exiting screensharing as presenter');
|
||||
}
|
||||
|
||||
if(this.kurentoScreenshare.webRtcPeer) {
|
||||
this.kurentoScreenshare.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
}
|
||||
|
||||
if (this.kurentoScreenshare.ws !== null) {
|
||||
this.kurentoScreenshare.ws.onclose = function () {};
|
||||
this.kurentoScreenshare.ws.close();
|
||||
}
|
||||
|
||||
if (this.kurentoScreenshare.pingInterval) {
|
||||
clearInterval(this.kurentoScreenshare.pingInterval);
|
||||
}
|
||||
|
||||
this.kurentoScreenshare.dispose();
|
||||
this.kurentoScreenshare = null;
|
||||
}
|
||||
};
|
||||
|
||||
KurentoManager.prototype.exitVideo = function () {
|
||||
try {
|
||||
if (typeof this.kurentoVideo !== 'undefined' && this.kurentoVideo) {
|
||||
if(this.kurentoVideo.webRtcPeer) {
|
||||
this.kurentoVideo.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
}
|
||||
|
||||
if (this.kurentoVideo.logger !== null) {
|
||||
this.kurentoScreenshare.logger.info({ logCode: 'kurentoextension_exit_screenshare_viewer' },
|
||||
'Exiting screensharing as viewer');
|
||||
}
|
||||
|
||||
if (this.kurentoVideo.ws !== null) {
|
||||
this.kurentoVideo.ws.onclose = function () {};
|
||||
this.kurentoVideo.ws.close();
|
||||
}
|
||||
|
||||
if (this.kurentoVideo.pingInterval) {
|
||||
clearInterval(this.kurentoVideo.pingInterval);
|
||||
}
|
||||
|
||||
this.kurentoVideo.dispose();
|
||||
this.kurentoVideo = null;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.kurentoVideo) {
|
||||
this.kurentoVideo.dispose();
|
||||
this.kurentoVideo = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
KurentoManager.prototype.exitAudio = function () {
|
||||
if (typeof this.kurentoAudio !== 'undefined' && this.kurentoAudio) {
|
||||
if (this.kurentoAudio.logger !== null) {
|
||||
this.kurentoAudio.logger.info({ logCode: 'kurentoextension_exit_listen_only' },
|
||||
'Exiting listen only');
|
||||
}
|
||||
|
||||
if (this.kurentoAudio.webRtcPeer) {
|
||||
this.kurentoAudio.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
}
|
||||
|
||||
if (this.kurentoAudio.ws !== null) {
|
||||
this.kurentoAudio.ws.onclose = function () {};
|
||||
this.kurentoAudio.ws.close();
|
||||
}
|
||||
|
||||
if (this.kurentoAudio.pingInterval) {
|
||||
clearInterval(this.kurentoAudio.pingInterval);
|
||||
}
|
||||
|
||||
this.kurentoAudio.dispose();
|
||||
this.kurentoAudio = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
KurentoManager.prototype.shareScreen = function (tag) {
|
||||
this.exitScreenShare();
|
||||
const obj = Object.create(Kurento.prototype);
|
||||
Kurento.apply(obj, arguments);
|
||||
this.kurentoScreenshare = obj;
|
||||
this.kurentoScreenshare.setScreensharing(tag);
|
||||
};
|
||||
|
||||
KurentoManager.prototype.joinWatchVideo = function (tag) {
|
||||
this.exitVideo();
|
||||
const obj = Object.create(Kurento.prototype);
|
||||
Kurento.apply(obj, arguments);
|
||||
this.kurentoVideo = obj;
|
||||
this.kurentoVideo.setWatchVideo(tag);
|
||||
};
|
||||
|
||||
KurentoManager.prototype.getFirefoxScreenshareSource = function () {
|
||||
return this.kurentoScreenshare.firefoxScreenshareSource;
|
||||
};
|
||||
|
||||
KurentoManager.prototype.getChromeScreenshareSources = function () {
|
||||
return this.kurentoScreenshare.chromeScreenshareSources;
|
||||
};
|
||||
|
||||
KurentoManager.prototype.getChromeExtensionKey = function () {
|
||||
return this.kurentoScreenshare.chromeExtension;
|
||||
};
|
||||
|
||||
|
||||
Kurento.prototype.setScreensharing = function (tag) {
|
||||
this.mediaCallback = this.startScreensharing.bind(this);
|
||||
this.create(tag);
|
||||
};
|
||||
|
||||
Kurento.prototype.create = function (tag) {
|
||||
this.setRenderTag(tag);
|
||||
this.init();
|
||||
};
|
||||
|
||||
Kurento.prototype.downscaleResolution = function (oldWidth, oldHeight) {
|
||||
const factorWidth = this.vid_max_width / oldWidth;
|
||||
const factorHeight = this.vid_max_height / oldHeight;
|
||||
let width,
|
||||
height;
|
||||
|
||||
if (factorWidth < factorHeight) {
|
||||
width = Math.trunc(oldWidth * factorWidth);
|
||||
height = Math.trunc(oldHeight * factorWidth);
|
||||
} else {
|
||||
width = Math.trunc(oldWidth * factorHeight);
|
||||
height = Math.trunc(oldHeight * factorHeight);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
Kurento.prototype.init = function () {
|
||||
const self = this;
|
||||
if ('WebSocket' in window) {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
this.ws.onmessage = this.onWSMessage.bind(this);
|
||||
this.ws.onclose = () => {
|
||||
kurentoManager.exitScreenShare();
|
||||
this.logger.error({ logCode: 'kurentoextension_websocket_close' },
|
||||
'WebSocket connection to SFU closed unexpectedly, screenshare/listen only will drop');
|
||||
self.onFail('Websocket connection closed');
|
||||
};
|
||||
this.ws.onerror = (error) => {
|
||||
kurentoManager.exitScreenShare();
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_websocket_error',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' }
|
||||
}, 'Error in the WebSocket connection to SFU, screenshare/listen only will drop');
|
||||
self.onFail('Websocket connection error');
|
||||
};
|
||||
this.ws.onopen = function () {
|
||||
self.pingInterval = setInterval(self.ping.bind(self), self.PING_INTERVAL);
|
||||
self.mediaCallback();
|
||||
};
|
||||
} else {
|
||||
this.logger.info({ logCode: 'kurentoextension_websocket_unsupported'},
|
||||
'Browser does not support websockets');
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.onWSMessage = function (message) {
|
||||
const parsedMessage = JSON.parse(message.data);
|
||||
switch (parsedMessage.id) {
|
||||
case 'startResponse':
|
||||
this.startResponse(parsedMessage);
|
||||
break;
|
||||
case 'stopSharing':
|
||||
kurentoManager.exitScreenShare();
|
||||
break;
|
||||
case 'iceCandidate':
|
||||
this.handleIceCandidate(parsedMessage.candidate);
|
||||
break;
|
||||
case 'webRTCAudioSuccess':
|
||||
this.onSuccess(parsedMessage.success);
|
||||
break;
|
||||
case 'webRTCAudioError':
|
||||
case 'error':
|
||||
this.handleSFUError(parsedMessage);
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
default:
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_unrecognized_sfu_message',
|
||||
extraInfo: { sfuResponse: parsedMessage }
|
||||
}, `Unrecognized SFU message: ${parsedMessage.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.setRenderTag = function (tag) {
|
||||
this.renderTag = tag;
|
||||
};
|
||||
|
||||
Kurento.prototype.processIceQueue = function () {
|
||||
const peer = this.webRtcPeer;
|
||||
while (peer.iceQueue.length) {
|
||||
const candidate = peer.iceQueue.shift();
|
||||
peer.addIceCandidate(candidate, (error) => {
|
||||
if (error) {
|
||||
// Just log the error. We can't be sure if a candidate failure on add is
|
||||
// fatal or not, so that's why we have a timeout set up for negotiations and
|
||||
// listeners for ICE state transitioning to failures, so we won't act on it here
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_addicecandidate_error',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `Adding ICE candidate failed due to ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Kurento.prototype.handleIceCandidate = function (candidate) {
|
||||
const peer = this.webRtcPeer;
|
||||
if (peer.negotiated) {
|
||||
peer.addIceCandidate(candidate, (error) => {
|
||||
if (error) {
|
||||
// Just log the error. We can't be sure if a candidate failure on add is
|
||||
// fatal or not, so that's why we have a timeout set up for negotiations and
|
||||
// listeners for ICE state transitioning to failures, so we won't act on it here
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_addicecandidate_error',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `Adding ICE candidate failed due to ${error.message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// ICE candidates are queued until a SDP answer has been processed.
|
||||
// This was done due to a long term iOS/Safari quirk where it'd
|
||||
// fail if candidates were added before the offer/answer cycle was completed.
|
||||
// IT STILL HAPPENS - prlanzarin sept 2019
|
||||
peer.iceQueue.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Kurento.prototype.startResponse = function (message) {
|
||||
if (message.response !== 'accepted') {
|
||||
this.handleSFUError(message);
|
||||
} else {
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_start_success',
|
||||
extraInfo: { sfuResponse: message }
|
||||
}, `Start request accepted for ${message.type}`);
|
||||
|
||||
this.webRtcPeer.processAnswer(message.sdpAnswer, (error) => {
|
||||
if (error) {
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_peerconnection_processanswer_error',
|
||||
extraInfo: {
|
||||
errorMessage: error.name || error.message || 'Unknown error',
|
||||
},
|
||||
}, `Processing SDP answer from SFU for failed due to ${error.message}`);
|
||||
|
||||
return this.onFail(error);
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_process_answer',
|
||||
}, `Answer processed with success`);
|
||||
|
||||
// Mark the peer as negotiated and flush the ICE queue
|
||||
this.webRtcPeer.negotiated = true;
|
||||
this.processIceQueue();
|
||||
// audio calls gets their success callback in a subsequent step (@webRTCAudioSuccess)
|
||||
// due to legacy messaging which I don't intend to break now - prlanzarin
|
||||
if (message.type === 'screenshare') {
|
||||
this.onSuccess()
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.handleSFUError = function (sfuResponse) {
|
||||
const { type, code, reason, role } = sfuResponse;
|
||||
switch (type) {
|
||||
case 'screenshare':
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_start_rejected',
|
||||
extraInfo: { sfuResponse }
|
||||
}, `SFU screenshare rejected by SFU with error ${code} = ${reason}`);
|
||||
|
||||
if (role === this.SEND_ROLE) {
|
||||
kurentoManager.exitScreenShare();
|
||||
} else if (role === this.RECV_ROLE) {
|
||||
kurentoManager.exitVideo();
|
||||
}
|
||||
break;
|
||||
case 'audio':
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_listenonly_start_rejected',
|
||||
extraInfo: { sfuResponse }
|
||||
}, `SFU listen only rejected by SFU with error ${code} = ${reason}`);
|
||||
|
||||
kurentoManager.exitAudio();
|
||||
break;
|
||||
}
|
||||
|
||||
this.onFail( { code, reason } );
|
||||
};
|
||||
|
||||
Kurento.prototype.onOfferPresenter = function (error, offerSdp) {
|
||||
const self = this;
|
||||
|
||||
if (error) {
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_presenter_offer_failure',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `Failed to generate peer connection offer for screenshare presenter with error ${error.message}`);
|
||||
this.onFail(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: 'start',
|
||||
type: this.SFU_APP,
|
||||
role: this.SEND_ROLE,
|
||||
internalMeetingId: self.internalMeetingId,
|
||||
voiceBridge: self.voiceBridge,
|
||||
callerName: self.userId,
|
||||
sdpOffer: offerSdp,
|
||||
vh: this.height,
|
||||
vw: this.width,
|
||||
userName: self.userName,
|
||||
};
|
||||
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_screenshare_request_start_presenter' ,
|
||||
extraInfo: { sfuRequest: message },
|
||||
}, `Screenshare presenter offer generated. Sending start request to SFU`);
|
||||
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
|
||||
Kurento.prototype.startScreensharing = function () {
|
||||
if (window.chrome) {
|
||||
if (this.chromeExtension == null && !hasDisplayMedia) {
|
||||
this.logger.error({ logCode: "kurentoextension_screenshare_noextensionkey" },
|
||||
'Screenshare hasnt got a Chrome extension key configured',
|
||||
);
|
||||
// TODO error handling here
|
||||
this.onFail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
localVideo: document.getElementById(this.renderTag),
|
||||
onicecandidate: (candidate) => {
|
||||
this.onIceCandidate(candidate, this.SEND_ROLE);
|
||||
},
|
||||
sendSource: 'desktop',
|
||||
videoStream: this.stream || undefined,
|
||||
};
|
||||
|
||||
let resolution;
|
||||
this.logger.debug({ logCode: 'kurentoextension_screenshare_screen_dimensions'},
|
||||
`Screenshare screen dimensions are ${this.width} x ${this.height}`);
|
||||
if (this.width > this.vid_max_width || this.height > this.vid_max_height) {
|
||||
resolution = this.downscaleResolution(this.width, this.height);
|
||||
this.width = resolution.width;
|
||||
this.height = resolution.height;
|
||||
this.logger.info({ logCode: 'kurentoextension_screenshare_track_resize' },
|
||||
`Screenshare track dimensions have been resized to ${this.width} x ${this.height}`);
|
||||
}
|
||||
|
||||
this.addIceServers(this.iceServers, options);
|
||||
|
||||
this.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerSendonly(options, (error) => {
|
||||
if (error) {
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_peerconnection_create_error',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `WebRTC peer constructor for screenshare (presenter) failed due to ${error.message}`);
|
||||
this.onFail(error);
|
||||
return kurentoManager.exitScreenShare();
|
||||
}
|
||||
|
||||
this.webRtcPeer.iceQueue = [];
|
||||
this.webRtcPeer.generateOffer(this.onOfferPresenter.bind(this));
|
||||
|
||||
const localStream = this.webRtcPeer.peerConnection.getLocalStreams()[0];
|
||||
const _this = this;
|
||||
localStream.getVideoTracks()[0].onended = function () {
|
||||
_this.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
return kurentoManager.exitScreenShare();
|
||||
};
|
||||
|
||||
localStream.getVideoTracks()[0].oninactive = function () {
|
||||
return kurentoManager.exitScreenShare();
|
||||
};
|
||||
});
|
||||
this.webRtcPeer.peerConnection.oniceconnectionstatechange = () => {
|
||||
if (this.webRtcPeer) {
|
||||
const iceConnectionState = this.webRtcPeer.peerConnection.iceConnectionState;
|
||||
if (iceConnectionState === 'failed' || iceConnectionState === 'closed') {
|
||||
this.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_presenter_ice_failed',
|
||||
extraInfo: { iceConnectionState }
|
||||
}, `WebRTC peer for screenshare presenter failed due to ICE transitioning to ${iceConnectionState}`);
|
||||
this.onFail({ message: 'iceConnectionStateError', code: 1108 });
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Kurento.prototype.onIceCandidate = function (candidate, role) {
|
||||
const self = this;
|
||||
this.logger.debug({
|
||||
logCode: 'kurentoextension_screenshare_client_candidate',
|
||||
extraInfo: { candidate }
|
||||
}, `Screenshare client-side candidate generated: ${JSON.stringify(candidate)}`);
|
||||
|
||||
const message = {
|
||||
id: this.ON_ICE_CANDIDATE_MSG,
|
||||
role,
|
||||
type: this.SFU_APP,
|
||||
voiceBridge: self.voiceBridge,
|
||||
candidate,
|
||||
callerName: self.userId,
|
||||
};
|
||||
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
Kurento.prototype.setWatchVideo = function (tag) {
|
||||
this.useVideo = true;
|
||||
this.useCamera = 'none';
|
||||
this.useMic = 'none';
|
||||
this.mediaCallback = this.viewer;
|
||||
this.create(tag);
|
||||
};
|
||||
|
||||
Kurento.prototype.viewer = function () {
|
||||
const self = this;
|
||||
if (!this.webRtcPeer) {
|
||||
const options = {
|
||||
mediaConstraints: {
|
||||
audio: false,
|
||||
},
|
||||
onicecandidate: (candidate) => {
|
||||
this.onIceCandidate(candidate, this.RECV_ROLE);
|
||||
},
|
||||
};
|
||||
|
||||
this.addIceServers(this.iceServers, options);
|
||||
|
||||
self.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly(options, function (error) {
|
||||
if (error) {
|
||||
return self.onFail(error);
|
||||
}
|
||||
self.webRtcPeer.iceQueue = [];
|
||||
this.generateOffer(self.onOfferViewer.bind(self));
|
||||
});
|
||||
self.webRtcPeer.peerConnection.oniceconnectionstatechange = () => {
|
||||
if (this.webRtcPeer) {
|
||||
const iceConnectionState = this.webRtcPeer.peerConnection.iceConnectionState;
|
||||
if (iceConnectionState === 'failed' || iceConnectionState === 'closed') {
|
||||
this.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_viewer_ice_failed',
|
||||
extraInfo: { iceConnectionState }
|
||||
}, `WebRTC peer for screenshare viewer failed due to ICE transitioning to ${iceConnectionState}`);
|
||||
this.onFail({ message: 'iceConnectionStateError', code: 1108 });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.onOfferViewer = function (error, offerSdp) {
|
||||
const self = this;
|
||||
if (error) {
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_screenshare_viewer_offer_failure',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `Failed to generate peer connection offer for screenshare viewer with error ${error.message}`);
|
||||
|
||||
return this.onFail(error);
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: 'start',
|
||||
type: this.SFU_APP,
|
||||
role: this.RECV_ROLE,
|
||||
internalMeetingId: self.internalMeetingId,
|
||||
voiceBridge: self.voiceBridge,
|
||||
callerName: self.userId,
|
||||
sdpOffer: offerSdp,
|
||||
userName: self.userName,
|
||||
};
|
||||
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_screenshare_request_start_viewer',
|
||||
extraInfo: { sfuRequest: message },
|
||||
}, `Screenshare viewer offer generated. Sending start request to SFU`);
|
||||
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
KurentoManager.prototype.joinAudio = function (tag) {
|
||||
this.exitAudio();
|
||||
const obj = Object.create(Kurento.prototype);
|
||||
Kurento.apply(obj, arguments);
|
||||
this.kurentoAudio = obj;
|
||||
this.kurentoAudio.setAudio(tag);
|
||||
};
|
||||
|
||||
Kurento.prototype.setAudio = function (tag) {
|
||||
this.mediaCallback = this.listenOnly.bind(this);
|
||||
this.create(tag);
|
||||
};
|
||||
|
||||
Kurento.prototype.listenOnly = function () {
|
||||
if (!this.webRtcPeer) {
|
||||
const options = {
|
||||
onicecandidate : this.onListenOnlyIceCandidate.bind(this),
|
||||
mediaConstraints: {
|
||||
audio: true,
|
||||
video: false,
|
||||
},
|
||||
};
|
||||
|
||||
this.addIceServers(this.iceServers, options);
|
||||
|
||||
this.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly(options, (error) => {
|
||||
if (error) {
|
||||
return this.onFail(error);
|
||||
}
|
||||
|
||||
this.webRtcPeer.iceQueue = [];
|
||||
this.webRtcPeer.peerConnection.oniceconnectionstatechange = () => {
|
||||
if (this.webRtcPeer) {
|
||||
const iceConnectionState = this.webRtcPeer.peerConnection.iceConnectionState;
|
||||
|
||||
if (iceConnectionState === 'failed' || iceConnectionState === 'closed') {
|
||||
this.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_listenonly_ice_failed',
|
||||
extraInfo: { iceConnectionState }
|
||||
}, `WebRTC peer for listen only failed due to ICE transitioning to ${iceConnectionState}`);
|
||||
this.onFail({
|
||||
errorCode: 1007,
|
||||
errorMessage: `ICE negotiation failed. Current state - ${iceConnectionState}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.webRtcPeer.generateOffer(this.onOfferListenOnly.bind(this));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.onListenOnlyIceCandidate = function (candidate) {
|
||||
const self = this;
|
||||
this.logger.debug({
|
||||
logCode: 'kurentoextension_listenonly_client_candidate',
|
||||
extraInfo: { candidate }
|
||||
}, `Listen only client-side candidate generated: ${JSON.stringify(candidate)}`);
|
||||
|
||||
const message = {
|
||||
id: this.ON_ICE_CANDIDATE_MSG,
|
||||
type: 'audio',
|
||||
role: 'viewer',
|
||||
voiceBridge: self.voiceBridge,
|
||||
candidate,
|
||||
};
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
Kurento.prototype.onOfferListenOnly = function (error, offerSdp) {
|
||||
const self = this;
|
||||
if (error) {
|
||||
this.logger.error({
|
||||
logCode: 'kurentoextension_listenonly_offer_failure',
|
||||
extraInfo: { errorMessage: error.name || error.message || 'Unknown error' },
|
||||
}, `Failed to generate peer connection offer for listen only with error ${error.message}`);
|
||||
|
||||
return this.onFail(error);
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: 'start',
|
||||
type: 'audio',
|
||||
role: 'viewer',
|
||||
voiceBridge: self.voiceBridge,
|
||||
caleeName: self.caleeName,
|
||||
sdpOffer: offerSdp,
|
||||
userId: self.userId,
|
||||
userName: self.userName,
|
||||
internalMeetingId: self.internalMeetingId,
|
||||
};
|
||||
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_listenonly_request_start',
|
||||
extraInfo: { sfuRequest: message },
|
||||
}, "Listen only offer generated. Sending start request to SFU");
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
Kurento.prototype.pauseTrack = function (message) {
|
||||
const localStream = this.webRtcPeer.peerConnection.getLocalStreams()[0];
|
||||
const track = localStream.getVideoTracks()[0];
|
||||
|
||||
if (track) {
|
||||
track.enabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.resumeTrack = function (message) {
|
||||
const localStream = this.webRtcPeer.peerConnection.getLocalStreams()[0];
|
||||
const track = localStream.getVideoTracks()[0];
|
||||
|
||||
if (track) {
|
||||
track.enabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.addIceServers = function (iceServers, options) {
|
||||
if (iceServers && iceServers.length > 0) {
|
||||
this.logger.debug({
|
||||
logCode: 'kurentoextension_add_iceservers',
|
||||
extraInfo: { iceServers }
|
||||
}, `Injecting ICE servers into peer creation`);
|
||||
|
||||
options.configuration = {};
|
||||
options.configuration.iceServers = iceServers;
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.stop = function () {
|
||||
// if (this.webRtcPeer) {
|
||||
// var message = {
|
||||
// id : 'stop',
|
||||
// type : 'screenshare',
|
||||
// voiceBridge: kurentoHandler.voiceBridge
|
||||
// }
|
||||
// kurentoHandler.sendMessage(message);
|
||||
// kurentoHandler.disposeScreenShare();
|
||||
// }
|
||||
};
|
||||
|
||||
Kurento.prototype.dispose = function () {
|
||||
if (this.webRtcPeer) {
|
||||
this.webRtcPeer.dispose();
|
||||
this.webRtcPeer = null;
|
||||
}
|
||||
};
|
||||
|
||||
Kurento.prototype.ping = function () {
|
||||
const message = {
|
||||
id: 'ping',
|
||||
};
|
||||
this.sendMessage(message);
|
||||
};
|
||||
|
||||
Kurento.prototype.sendMessage = function (message) {
|
||||
const jsonMessage = JSON.stringify(message);
|
||||
this.ws.send(jsonMessage);
|
||||
};
|
||||
|
||||
Kurento.normalizeCallback = function (callback) {
|
||||
if (typeof callback === 'function') {
|
||||
return callback;
|
||||
}
|
||||
return function (args) {
|
||||
document.getElementById('BigBlueButton')[callback](args);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/* Global methods */
|
||||
|
||||
// this function explains how to use above methods/objects
|
||||
window.getScreenConstraints = function (sendSource, callback) {
|
||||
let screenConstraints = { video: {}, audio: false };
|
||||
|
||||
// Limiting FPS to a range of 5-10 (5 ideal)
|
||||
screenConstraints.video.frameRate = { ideal: 5, max: 10 };
|
||||
|
||||
screenConstraints.video.height = { max: kurentoManager.kurentoScreenshare.vid_max_height };
|
||||
screenConstraints.video.width = { max: kurentoManager.kurentoScreenshare.vid_max_width };
|
||||
|
||||
const getChromeScreenConstraints = function (extensionKey) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(
|
||||
extensionKey,
|
||||
{
|
||||
getStream: true,
|
||||
sources: kurentoManager.getChromeScreenshareSources(),
|
||||
},
|
||||
(response) => {
|
||||
resolve(response);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayMediaConstraints = function () {
|
||||
// The fine-grained constraints (e.g.: frameRate) are supposed to go into
|
||||
// the MediaStream because getDisplayMedia does not support them,
|
||||
// so they're passed differently
|
||||
kurentoManager.kurentoScreenshare.extensionInstalled = true;
|
||||
optionalConstraints.width = { max: kurentoManager.kurentoScreenshare.vid_max_width };
|
||||
optionalConstraints.height = { max: kurentoManager.kurentoScreenshare.vid_max_height };
|
||||
optionalConstraints.frameRate = { ideal: 5, max: 10 };
|
||||
|
||||
let gDPConstraints = {
|
||||
video: true,
|
||||
optional: optionalConstraints
|
||||
};
|
||||
|
||||
return gDPConstraints;
|
||||
};
|
||||
|
||||
const optionalConstraints = [
|
||||
{ googCpuOveruseDetection: true },
|
||||
{ googCpuOveruseEncodeUsage: true },
|
||||
{ googCpuUnderuseThreshold: 55 },
|
||||
{ googCpuOveruseThreshold: 100 },
|
||||
{ googPayloadPadding: true },
|
||||
{ googScreencastMinBitrate: 600 },
|
||||
{ googHighStartBitrate: true },
|
||||
{ googHighBitrate: true },
|
||||
{ googVeryHighBitrate: true },
|
||||
];
|
||||
|
||||
if (isElectron) {
|
||||
const sourceId = ipcRenderer.sendSync('screen-chooseSync');
|
||||
kurentoManager.kurentoScreenshare.extensionInstalled = true;
|
||||
|
||||
// this statement sets gets 'sourceId" and sets "chromeMediaSourceId"
|
||||
screenConstraints.video.chromeMediaSource = { exact: [sendSource] };
|
||||
screenConstraints.video.chromeMediaSourceId = sourceId;
|
||||
screenConstraints.optional = optionalConstraints;
|
||||
|
||||
return callback(null, screenConstraints);
|
||||
}
|
||||
|
||||
if (isChrome) {
|
||||
if (!hasDisplayMedia) {
|
||||
const extensionKey = kurentoManager.getChromeExtensionKey();
|
||||
getChromeScreenConstraints(extensionKey).then((constraints) => {
|
||||
if (!constraints) {
|
||||
document.dispatchEvent(new Event('installChromeExtension'));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = constraints.streamId;
|
||||
|
||||
kurentoManager.kurentoScreenshare.extensionInstalled = true;
|
||||
|
||||
// Re-wrap the video constraints into the mandatory object (latest adapter)
|
||||
screenConstraints.video = {};
|
||||
screenConstraints.video.mandatory = {};
|
||||
screenConstraints.video.mandatory.maxFrameRate = 10;
|
||||
screenConstraints.video.mandatory.maxHeight = kurentoManager.kurentoScreenshare.vid_max_height;
|
||||
screenConstraints.video.mandatory.maxWidth = kurentoManager.kurentoScreenshare.vid_max_width;
|
||||
screenConstraints.video.mandatory.chromeMediaSource = sendSource;
|
||||
screenConstraints.video.mandatory.chromeMediaSourceId = sourceId;
|
||||
screenConstraints.optional = optionalConstraints;
|
||||
|
||||
return callback(null, screenConstraints);
|
||||
});
|
||||
} else {
|
||||
return callback(null, getDisplayMediaConstraints());
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirefox) {
|
||||
const firefoxScreenshareSource = kurentoManager.getFirefoxScreenshareSource();
|
||||
screenConstraints.video.mediaSource = firefoxScreenshareSource;
|
||||
return callback(null, screenConstraints);
|
||||
}
|
||||
|
||||
// Falls back to getDisplayMedia if the browser supports it
|
||||
if (hasDisplayMedia) {
|
||||
return callback(null, getDisplayMediaConstraints());
|
||||
}
|
||||
};
|
||||
|
||||
window.kurentoInitialize = function () {
|
||||
if (window.kurentoManager == null || window.KurentoManager === undefined) {
|
||||
window.kurentoManager = new KurentoManager();
|
||||
}
|
||||
};
|
||||
|
||||
window.kurentoShareScreen = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.shareScreen.apply(window.kurentoManager, arguments);
|
||||
};
|
||||
|
||||
|
||||
window.kurentoExitScreenShare = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.exitScreenShare();
|
||||
};
|
||||
|
||||
window.kurentoWatchVideo = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.joinWatchVideo.apply(window.kurentoManager, arguments);
|
||||
};
|
||||
|
||||
window.kurentoExitVideo = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.exitVideo();
|
||||
};
|
||||
|
||||
window.kurentoJoinAudio = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.joinAudio.apply(window.kurentoManager, arguments);
|
||||
};
|
||||
|
||||
window.kurentoExitAudio = function () {
|
||||
window.kurentoInitialize();
|
||||
window.kurentoManager.exitAudio();
|
||||
};
|
@ -127,10 +127,11 @@
|
||||
"app.media.screenshare.notSupported": "Screensharing is not supported in this browser.",
|
||||
"app.media.screenshare.autoplayBlockedDesc": "We need your permission to show you the presenter's screen.",
|
||||
"app.media.screenshare.autoplayAllowLabel": "View shared screen",
|
||||
"app.screenshare.notAllowed": "Error: Permission to access screen wasn't granted.",
|
||||
"app.screenshare.notSupportedError": "Error: Screensharing is allowed only on safe (SSL) domains",
|
||||
"app.screenshare.notReadableError": "Error: There was a failure while trying to capture your screen",
|
||||
"app.screenshare.genericError": "Error: An error has occurred with screensharing, please try again",
|
||||
"app.screenshare.screenshareFinalError": "Code {0}. Could not share the screen.",
|
||||
"app.screenshare.screenshareRetryError": "Code {0}. Try sharing the screen again.",
|
||||
"app.screenshare.screenshareRetryOtherEnvError": "Code {0}. Could not share the screen. Try again using a different browser or device.",
|
||||
"app.screenshare.screenshareUnsupportedEnv": "Code {0}. Browser is not supported. Try again using a different browser or device.",
|
||||
"app.screenshare.screensharePermissionError": "Code {0}. Permission to capture the screen needs to be granted.",
|
||||
"app.meeting.ended": "This session has ended",
|
||||
"app.meeting.meetingTimeRemaining": "Meeting time remaining: {0}",
|
||||
"app.meeting.meetingTimeHasEnded": "Time ended. Meeting will close soon",
|
||||
@ -662,7 +663,6 @@
|
||||
"app.video.clientDisconnected": "Webcam cannot be shared due to connection issues",
|
||||
"app.fullscreenButton.label": "Make {0} fullscreen",
|
||||
"app.fullscreenUndoButton.label": "Undo {0} fullscreen",
|
||||
"app.deskshare.iceConnectionStateError": "Connection failed when sharing screen (ICE error 1108)",
|
||||
"app.sfu.mediaServerConnectionError2000": "Unable to connect to media server (error 2000)",
|
||||
"app.sfu.mediaServerOffline2001": "Media server is offline. Please try again later (error 2001)",
|
||||
"app.sfu.mediaServerNoResources2002": "Media server has no available resources (error 2002)",
|
||||
|
Loading…
Reference in New Issue
Block a user