Merged upstream develop branch

This commit is contained in:
Michael Zinsmeister 2021-03-14 22:17:19 +01:00
commit b99bd3be0c
190 changed files with 3436 additions and 2496 deletions

View File

@ -7,6 +7,7 @@ trait GuestsApp extends GetGuestsWaitingApprovalReqMsgHdlr
with GuestsWaitingApprovedMsgHdlr
with GuestWaitingLeftMsgHdlr
with SetGuestPolicyMsgHdlr
with SetGuestLobbyMessageMsgHdlr
with GetGuestPolicyReqMsgHdlr {
this: MeetingActor =>

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")

View File

@ -26,7 +26,7 @@ trait RegisterUserReqMsgHdlr {
val regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId,
msg.body.name, msg.body.role, msg.body.authToken,
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus)
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, false)
RegisteredUsers.add(liveMeeting.registeredUsers, regUser)

View File

@ -2,7 +2,7 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs.UserLeaveReqMsg
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models.Users2x
import org.bigbluebutton.core.models.{ RegisteredUsers, Users2x }
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
trait UserLeaveReqMsgHdlr {
@ -21,6 +21,15 @@ trait UserLeaveReqMsgHdlr {
// ralam oct 23, 2018
Users2x.setUserLeftFlag(liveMeeting.users2x, msg.body.userId)
}
if (msg.body.loggedOut) {
log.info("Setting user logged out flag. user {} meetingId={}", msg.body.userId, msg.header.meetingId)
for {
ru <- RegisteredUsers.findWithUserId(msg.body.userId, liveMeeting.registeredUsers)
} yield {
RegisteredUsers.setUserLoggedOutFlag(liveMeeting.registeredUsers, ru)
}
}
state
case None =>
state

View File

@ -22,18 +22,20 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
val regUser = RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId,
liveMeeting.registeredUsers)
regUser match {
case Some(u) =>
// Check if banned user is rejoining.
// Fail validation if ejected user is rejoining.
// ralam april 21, 2020
if (u.guestStatus == GuestStatus.ALLOW && !u.banned) {
if (u.guestStatus == GuestStatus.ALLOW && !u.banned && !u.loggedOut) {
userValidated(u, state)
} else {
if (u.banned) {
failReason = "Ejected user rejoining"
failReasonCode = EjectReasonCode.EJECTED_USER_REJOINING
} else if (u.loggedOut) {
failReason = "User had logged out"
failReasonCode = EjectReasonCode.USER_LOGGED_OUT
}
validateTokenFailed(
outGW,
@ -75,7 +77,7 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
reasonCode: String,
state: MeetingState2x
): MeetingState2x = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, 0, 0)
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, 0, 0, Option.apply(reason))
outGW.send(event)
// send a system message to force disconnection
@ -86,8 +88,8 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
}
def sendValidateAuthTokenRespMsg(meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long): Unit = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn)
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String] = None): Unit = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
outGW.send(event)
}

View File

@ -24,6 +24,9 @@ object GuestsWaiting {
guests.setGuestPolicy(policy)
}
def setGuestLobbyMessage(guests: GuestsWaiting, message: String): Unit = {
guests.setGuestLobbyMessage(message)
}
}
class GuestsWaiting {
@ -31,6 +34,8 @@ class GuestsWaiting {
private var guestPolicy = GuestPolicy(GuestPolicyType.ALWAYS_ACCEPT, SystemUser.ID)
private var guestLobbyMessage = ""
private def toVector: Vector[GuestWaiting] = guests.values.toVector
private def save(user: GuestWaiting): GuestWaiting = {
@ -49,6 +54,8 @@ class GuestsWaiting {
def getGuestPolicy(): GuestPolicy = guestPolicy
def setGuestPolicy(policy: GuestPolicy) = guestPolicy = policy
def setGuestLobbyMessage(message: String) = guestLobbyMessage = message
}
case class GuestWaiting(intId: String, name: String, role: String, guest: Boolean, avatar: String, authenticated: Boolean, registeredOn: Long)

View File

@ -5,7 +5,7 @@ import com.softwaremill.quicklens._
object RegisteredUsers {
def create(userId: String, extId: String, name: String, roles: String,
token: String, avatar: String, guest: Boolean, authenticated: Boolean,
guestStatus: String): RegisteredUser = {
guestStatus: String, loggedOut: Boolean): RegisteredUser = {
new RegisteredUser(
userId,
extId,
@ -20,7 +20,8 @@ object RegisteredUsers {
0,
false,
false,
false
false,
loggedOut
)
}
@ -138,6 +139,13 @@ object RegisteredUsers {
users.save(u)
u
}
def setUserLoggedOutFlag(users: RegisteredUsers, user: RegisteredUser): RegisteredUser = {
val u = user.copy(loggedOut = true)
users.save(u)
u
}
}
class RegisteredUsers {
@ -173,6 +181,7 @@ case class RegisteredUser(
lastAuthTokenValidatedOn: Long,
joined: Boolean,
markAsJoinTimedOut: Boolean,
banned: Boolean
banned: Boolean,
loggedOut: Boolean
)

View File

@ -307,4 +307,5 @@ object EjectReasonCode {
val VALIDATE_TOKEN = "validate_token_failed_eject_reason"
val USER_INACTIVITY = "user_inactivity_eject_reason"
val EJECTED_USER_REJOINING = "ejected_user_rejoining_reason"
val USER_LOGGED_OUT = "user_logged_out_reason"
}

View File

@ -95,6 +95,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[SetGuestPolicyCmdMsg](envelope, jsonNode)
case GetGuestPolicyReqMsg.NAME =>
routeGenericMsg[GetGuestPolicyReqMsg](envelope, jsonNode)
case SetGuestLobbyMessageCmdMsg.NAME =>
routeGenericMsg[SetGuestLobbyMessageCmdMsg](envelope, jsonNode)
// Users
case GetUsersMeetingReqMsg.NAME =>

View File

@ -443,6 +443,7 @@ class MeetingActor(
// Guests
case m: GetGuestsWaitingApprovalReqMsg => handleGetGuestsWaitingApprovalReqMsg(m)
case m: SetGuestPolicyCmdMsg => handleSetGuestPolicyMsg(m)
case m: SetGuestLobbyMessageCmdMsg => handleSetGuestLobbyMessageMsg(m)
case m: GuestsWaitingApprovedMsg => handleGuestsWaitingApprovedMsg(m)
case m: GuestWaitingLeftMsg => handleGuestWaitingLeftMsg(m)
case m: GetGuestPolicyReqMsg => handleGetGuestPolicyReqMsg(m)

View File

@ -139,6 +139,8 @@ class AnalyticsActor extends Actor with ActorLogging {
case m: GuestsWaitingForApprovalEvtMsg => logMessage(msg)
case m: SetGuestPolicyCmdMsg => logMessage(msg)
case m: GuestPolicyChangedEvtMsg => logMessage(msg)
case m: SetGuestLobbyMessageCmdMsg => logMessage(msg)
case m: GuestLobbyMessageChangedEvtMsg => logMessage(msg)
// System
case m: ClientToServerLatencyTracerMsg => traceMessage(msg)

View File

@ -0,0 +1,31 @@
package org.bigbluebutton.core2.message.handlers.guests
import org.bigbluebutton.common2.msgs.SetGuestLobbyMessageCmdMsg
import org.bigbluebutton.core.models.{ GuestsWaiting }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.MsgBuilder
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.running.MeetingActor
trait SetGuestLobbyMessageMsgHdlr extends RightsManagementTrait {
this: MeetingActor =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleSetGuestLobbyMessageMsg(msg: SetGuestLobbyMessageCmdMsg): Unit = {
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to set guest lobby message in meeting."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
} else {
GuestsWaiting.setGuestLobbyMessage(liveMeeting.guestsWaiting, msg.body.message)
val event = MsgBuilder.buildGuestLobbyMessageChangedEvtMsg(
liveMeeting.props.meetingProp.intId,
msg.header.userId,
msg.body.message
)
outGW.send(event)
}
}
}

View File

@ -16,6 +16,17 @@ object MsgBuilder {
BbbCommonEnvCoreMsg(envelope, event)
}
def buildGuestLobbyMessageChangedEvtMsg(meetingId: String, userId: String, message: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
val envelope = BbbCoreEnvelope(GuestLobbyMessageChangedEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GuestLobbyMessageChangedEvtMsg.NAME, meetingId, userId)
val body = GuestLobbyMessageChangedEvtMsgBody(message)
val event = GuestLobbyMessageChangedEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildGuestApprovedEvtMsg(meetingId: String, userId: String, status: String, approvedBy: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(GuestApprovedEvtMsg.NAME, routing)
@ -74,11 +85,11 @@ object MsgBuilder {
}
def buildValidateAuthTokenRespMsg(meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long): BbbCommonEnvCoreMsg = {
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String]): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(ValidateAuthTokenRespMsg.NAME, routing)
val header = BbbClientMsgHeader(ValidateAuthTokenRespMsg.NAME, meetingId, userId)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
val event = ValidateAuthTokenRespMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}

View File

@ -6,11 +6,11 @@ import org.bigbluebutton.core.running.OutMsgRouter
object ValidateAuthTokenRespMsgSender {
def send(outGW: OutMsgRouter, meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long): Unit = {
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String]): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(ValidateAuthTokenRespMsg.NAME, routing)
val header = BbbClientMsgHeader(ValidateAuthTokenRespMsg.NAME, meetingId, userId)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
val event = ValidateAuthTokenRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)

View File

@ -54,7 +54,7 @@ object FakeUserGenerator {
RandomStringGenerator.randomAlphanumericString(10) + ".png"
val ru = RegisteredUsers.create(userId = id, extId, name, role,
authToken, avatarURL, guest, authed, guestStatus = GuestStatus.ALLOW)
authToken, avatarURL, guest, authed, guestStatus = GuestStatus.ALLOW, false)
RegisteredUsers.add(users, ru)
ru
}

View File

@ -14,7 +14,7 @@ object TestDataGen {
RandomStringGenerator.randomAlphanumericString(10) + ".png"
val ru = RegisteredUsers.create(userId = id, extId, name, role,
authToken, avatarURL, guest, authed, GuestStatus.ALLOW)
authToken, avatarURL, guest, authed, GuestStatus.ALLOW, false)
RegisteredUsers.add(users, ru)
ru

View File

@ -79,4 +79,4 @@ daemonGroup in Linux := group
javaOptions in Universal ++= Seq("-J-Xms130m", "-J-Xmx256m", "-Dconfig.file=/etc/bigbluebutton/bbb-fsesl-akka.conf", "-Dlogback.configurationFile=conf/logback.xml")
debianPackageDependencies in Debian ++= Seq("java8-runtime-headless", "bash")
debianPackageDependencies in Debian ++= Seq("java8-runtime-headless", "bash", "bbb-freeswitch-core")

View File

@ -0,0 +1,10 @@
#
# Update ESL password to match the password from bbb-freeswitch-core, which is
# listed as a package dependency to ensure that it configures first.
#
ESL_PASSWORD=$(xmlstarlet sel -t -m 'configuration/settings/param[@name="password"]' -v @value /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)
if [ -n "$ESL_PASSWORD" ]; then
sed -i "s/ClueCon/$ESL_PASSWORD/g" /etc/bigbluebutton/bbb-fsesl-akka.conf
fi

View File

@ -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(),

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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)

View File

@ -103,6 +103,26 @@ case class GuestPolicyChangedEvtMsg(
) extends BbbCoreMsg
case class GuestPolicyChangedEvtMsgBody(policy: String, setBy: String)
/**
* Message from user to set the guest lobby message.
*/
object SetGuestLobbyMessageCmdMsg { val NAME = "SetGuestLobbyMessageCmdMsg" }
case class SetGuestLobbyMessageCmdMsg(
header: BbbClientMsgHeader,
body: SetGuestLobbyMessageCmdMsgBody
) extends StandardMsg
case class SetGuestLobbyMessageCmdMsgBody(message: String)
/**
* Message sent to all clients that guest lobby message has been changed.
*/
object GuestLobbyMessageChangedEvtMsg { val NAME = "GuestLobbyMessageChangedEvtMsg" }
case class GuestLobbyMessageChangedEvtMsg(
header: BbbClientMsgHeader,
body: GuestLobbyMessageChangedEvtMsgBody
) extends BbbCoreMsg
case class GuestLobbyMessageChangedEvtMsgBody(message: String)
/**
* Message from user to get the guest policy.
*/

View File

@ -59,7 +59,8 @@ case class ValidateAuthTokenRespMsg(
header: BbbClientMsgHeader,
body: ValidateAuthTokenRespMsgBody
) extends BbbCoreMsg
case class ValidateAuthTokenRespMsgBody(userId: String, authToken: String, valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long)
case class ValidateAuthTokenRespMsgBody(userId: String, authToken: String, valid: Boolean, waitForApproval: Boolean,
registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String])
object UserLeftMeetingEvtMsg {
val NAME = "UserLeftMeetingEvtMsg"
@ -301,7 +302,7 @@ case class UserJoinMeetingAfterReconnectReqMsgBody(userId: String, authToken: St
*/
object UserLeaveReqMsg { val NAME = "UserLeaveReqMsg" }
case class UserLeaveReqMsg(header: BbbClientMsgHeader, body: UserLeaveReqMsgBody) extends StandardMsg
case class UserLeaveReqMsgBody(userId: String, sessionId: String)
case class UserLeaveReqMsgBody(userId: String, sessionId: String, loggedOut: Boolean)
object GetUsersMeetingReqMsg { val NAME = "GetUsersMeetingReqMsg" }
case class GetUsersMeetingReqMsg(header: BbbClientMsgHeader, body: GetUsersMeetingReqMsgBody) extends StandardMsg

View File

@ -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.

View File

@ -61,6 +61,7 @@ import org.bigbluebutton.api.messaging.messages.CreateBreakoutRoom;
import org.bigbluebutton.api.messaging.messages.CreateMeeting;
import org.bigbluebutton.api.messaging.messages.EndMeeting;
import org.bigbluebutton.api.messaging.messages.GuestPolicyChanged;
import org.bigbluebutton.api.messaging.messages.GuestLobbyMessageChanged;
import org.bigbluebutton.api.messaging.messages.GuestStatusChangedEventMsg;
import org.bigbluebutton.api.messaging.messages.GuestsStatus;
import org.bigbluebutton.api.messaging.messages.IMessage;
@ -1101,6 +1102,8 @@ public class MeetingService implements MessageListener {
processGuestStatusChangedEventMsg((GuestStatusChangedEventMsg) message);
} else if (message instanceof GuestPolicyChanged) {
processGuestPolicyChanged((GuestPolicyChanged) message);
} else if (message instanceof GuestLobbyMessageChanged) {
processGuestLobbyMessageChanged((GuestLobbyMessageChanged) message);
} else if (message instanceof RecordChapterBreak) {
processRecordingChapterBreak((RecordChapterBreak) message);
} else if (message instanceof AddPad) {
@ -1125,6 +1128,13 @@ public class MeetingService implements MessageListener {
}
}
public void processGuestLobbyMessageChanged(GuestLobbyMessageChanged msg) {
Meeting m = getMeeting(msg.meetingId);
if (m != null) {
m.setGuestLobbyMessage(msg.message);
}
}
public void processAddPad(AddPad msg) {
Meeting m = getMeeting(msg.meetingId);
if (m != null) {

View File

@ -68,6 +68,7 @@ public class Meeting {
private String defaultAvatarURL;
private String defaultConfigToken;
private String guestPolicy = GuestPolicy.ASK_MODERATOR;
private String guestLobbyMessage = "";
private Boolean authenticatedGuest = false;
private boolean userHasJoined = false;
private Map<String, String> pads;
@ -376,6 +377,14 @@ public class Meeting {
return guestPolicy;
}
public void setGuestLobbyMessage(String message) {
guestLobbyMessage = message;
}
public String getGuestLobbyMessage() {
return guestLobbyMessage;
}
public void setAuthenticatedGuest(Boolean authGuest) {
authenticatedGuest = authGuest;
}

View File

@ -0,0 +1,11 @@
package org.bigbluebutton.api.messaging.messages;
public class GuestLobbyMessageChanged implements IMessage {
public final String meetingId;
public final String message;
public GuestLobbyMessageChanged(String meetingId, String message) {
this.meetingId = meetingId;
this.message = message;
}
}

View File

@ -94,6 +94,8 @@ class ReceivedJsonMsgHdlrActor(val msgFromAkkaAppsEventBus: MsgFromAkkaAppsEvent
route[GuestsWaitingApprovedEvtMsg](envelope, jsonNode)
case GuestPolicyChangedEvtMsg.NAME =>
route[GuestPolicyChangedEvtMsg](envelope, jsonNode)
case GuestLobbyMessageChangedEvtMsg.NAME =>
route[GuestLobbyMessageChangedEvtMsg](envelope, jsonNode)
case AddPadEvtMsg.NAME =>
route[AddPadEvtMsg](envelope, jsonNode)
case AddCaptionsPadsEvtMsg.NAME =>

View File

@ -39,6 +39,7 @@ class OldMeetingMsgHdlrActor(val olgMsgGW: OldMessageReceivedGW)
case m: PresentationUploadTokenSysPubMsg => handlePresentationUploadTokenSysPubMsg(m)
case m: GuestsWaitingApprovedEvtMsg => handleGuestsWaitingApprovedEvtMsg(m)
case m: GuestPolicyChangedEvtMsg => handleGuestPolicyChangedEvtMsg(m)
case m: GuestLobbyMessageChangedEvtMsg => handleGuestLobbyMessageChangedEvtMsg(m)
case m: AddCaptionsPadsEvtMsg => handleAddCaptionsPadsEvtMsg(m)
case m: AddPadEvtMsg => handleAddPadEvtMsg(m)
case m: RecordingChapterBreakSysMsg => handleRecordingChapterBreakSysMsg(m)
@ -52,6 +53,10 @@ class OldMeetingMsgHdlrActor(val olgMsgGW: OldMessageReceivedGW)
olgMsgGW.handle(new GuestPolicyChanged(msg.header.meetingId, msg.body.policy))
}
def handleGuestLobbyMessageChangedEvtMsg(msg: GuestLobbyMessageChangedEvtMsg): Unit = {
olgMsgGW.handle(new GuestLobbyMessageChanged(msg.header.meetingId, msg.body.message))
}
def handleAddPadEvtMsg(msg: AddPadEvtMsg): Unit = {
olgMsgGW.handle(new AddPad(msg.header.meetingId, msg.body.padId, msg.body.readOnlyId))
}

View File

@ -23,15 +23,17 @@ else
SERVLET_DIR=/var/lib/tomcat7/webapps/bigbluebutton
fi
BBB_WEB_ETC_CONFIG=/etc/bigbluebutton/bbb-web.properties
PROTOCOL=http
if [ -f $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties ]; then
SERVER_URL=$(cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties | sed -n '/^bigbluebutton.web.serverURL/{s/.*\///;p}')
if cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties | grep bigbluebutton.web.serverURL | grep -q https; then
SERVER_URL=$(cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties $BBB_WEB_ETC_CONFIG | grep -v '#' | sed -n '/^bigbluebutton.web.serverURL/{s/.*\///;p}' | tail -n 1)
if cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties $BBB_WEB_ETC_CONFIG | grep -v '#' | grep bigbluebutton.web.serverURL | tail -n 1 | grep -q https; then
PROTOCOL=https
fi
fi
HOST=$(cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | sed -n '/^bigbluebutton.web.serverURL/{s/.*\///;p}')
HOST=$(cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties $BBB_WEB_ETC_CONFIG | grep -v '#' | sed -n '/^bigbluebutton.web.serverURL/{s/.*\///;p}' | tail -n 1)
HTML5_CONFIG=/usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml
BBB_WEB_CONFIG=$SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties

View File

@ -1304,6 +1304,7 @@ if [ $CHECK ]; then
echo " CPU cores: $NCPU"
echo
echo "$BBB_WEB_ETC_CONFIG (override for bbb-web)"
echo "$BBB_WEB_CONFIG (bbb-web)"
echo " bigbluebutton.web.serverURL: $(get_bbb_web_config_value bigbluebutton.web.serverURL)"
echo " defaultGuestPolicy: $(get_bbb_web_config_value defaultGuestPolicy)"
@ -1644,9 +1645,7 @@ if [ -n "$HOST" ]; then
fi
if [ -f $HTML5_CONFIG ]; then
WS=$(cat $SERVLET_DIR/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | sed -n '/^bigbluebutton.web.serverURL/{s/.*=//;p}' | sed 's/https/wss/g' | sed s'/http/ws/g')
yq w -i $HTML5_CONFIG public.kurento.wsUrl "$WS/bbb-webrtc-sfu"
yq w -i $HTML5_CONFIG public.kurento.wsUrl "wss://$HOST/bbb-webrtc-sfu"
yq w -i $HTML5_CONFIG public.note.url "$PROTOCOL://$HOST/pad"
chown meteor:meteor $HTML5_CONFIG

View File

@ -3,5 +3,7 @@ npm-debug.log
node_modules/
.meteor/dev_bundle
tests/webdriverio/.testing-env
public/locales/de_DE.json
public/locales/ja_JP.json

View File

@ -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">

View File

@ -39,6 +39,7 @@ const WEBSOCKET_KEEP_ALIVE_DEBOUNCE = MEDIA.websocketKeepAliveDebounce || 10;
const TRACE_SIP = MEDIA.traceSip || false;
const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings
.application.microphoneConstraints;
const SDP_SEMANTICS = MEDIA.sdpSemantics;
const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
@ -388,7 +389,7 @@ class SIPSession {
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: {
iceServers,
sdpSemantics: 'plan-b',
sdpSemantics: SDP_SEMANTICS,
},
},
displayName: callerIdName,

View File

@ -3,8 +3,12 @@ import sendGroupChatMsg from './methods/sendGroupChatMsg';
import clearPublicChatHistory from './methods/clearPublicChatHistory';
import startUserTyping from './methods/startUserTyping';
import stopUserTyping from './methods/stopUserTyping';
import chatMessageBeforeJoinCounter from './methods/chatMessageBeforeJoinCounter';
import fetchMessagePerPage from './methods/fetchMessagePerPage';
Meteor.methods({
fetchMessagePerPage,
chatMessageBeforeJoinCounter,
sendGroupChatMsg,
clearPublicChatHistory,
startUserTyping,

View File

@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import GroupChat from '/imports/api/group-chat';
import { GroupChatMsg } from '/imports/api/group-chat-msg';
import Users from '/imports/api/users';
import { extractCredentials } from '/imports/api/common/server/helpers';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_TYPE = CHAT_CONFIG.type_public;
export default function chatMessageBeforeJoinCounter() {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const groupChats = GroupChat.find({
$or: [
{ meetingId, access: PUBLIC_CHAT_TYPE },
{ meetingId, users: { $all: [requesterUserId] } },
],
}).fetch();
const User = Users.findOne({ userId: requesterUserId, meetingId });
const chatIdWithCounter = groupChats.map((groupChat) => {
const msgCount = GroupChatMsg.find({ chatId: groupChat.chatId, timestamp: { $lt: User.authTokenValidatedTime } }).count();
return {
chatId: groupChat.chatId,
count: msgCount,
};
}).filter(chat => chat.count);
return chatIdWithCounter;
}

View File

@ -0,0 +1,22 @@
import { Meteor } from 'meteor/meteor';
import GroupChat from '/imports/api/group-chat';
import { GroupChatMsg } from '/imports/api/group-chat-msg';
import Users from '/imports/api/users';
import { extractCredentials } from '/imports/api/common/server/helpers';
const CHAT_CONFIG = Meteor.settings.public.chat;
const ITENS_PER_PAGE = CHAT_CONFIG.itemsPerPage;
export default function fetchMessagePerPage(chatId, page) {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const User = Users.findOne({ userId: requesterUserId, meetingId });
const messages = GroupChatMsg.find({ chatId, meetingId, timestamp: { $lt: User.authTokenValidatedTime } },
{
sort: { timestamp: 1 },
skip: page > 0 ? ((page - 1) * ITENS_PER_PAGE) : 0,
limit: ITENS_PER_PAGE,
})
.fetch();
return messages;
}

View File

@ -1,4 +1,5 @@
import { GroupChatMsg, UsersTyping } from '/imports/api/group-chat-msg';
import Users from '/imports/api/users';
import { Meteor } from 'meteor/meteor';
import Logger from '/imports/startup/server/logger';
@ -19,7 +20,9 @@ function groupChatMsg(chatsIds) {
Logger.debug('Publishing group-chat-msg', { meetingId, userId });
const User = Users.findOne({ userId });
const selector = {
timestamp: { $gte: User.authTokenValidatedTime },
$or: [
{ meetingId, chatId: { $eq: PUBLIC_GROUP_CHAT_ID } },
{ chatId: { $in: chatsIds } },

View File

@ -1,8 +1,10 @@
import { Meteor } from 'meteor/meteor';
import allowPendingUsers from '/imports/api/guest-users/server/methods/allowPendingUsers';
import changeGuestPolicy from '/imports/api/guest-users/server/methods/changeGuestPolicy';
import setGuestLobbyMessage from '/imports/api/guest-users/server/methods/setGuestLobbyMessage';
Meteor.methods({
allowPendingUsers,
changeGuestPolicy,
setGuestLobbyMessage,
});

View File

@ -0,0 +1,24 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers';
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'SetGuestLobbyMessageCmdMsg';
export default function setGuestLobbyMessage(message) {
check(message, String);
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
const payload = { message };
Logger.info(`User=${requesterUserId} set guest lobby message to ${message}`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -4,6 +4,8 @@ import handleGetAllMeetings from './handlers/getAllMeetings';
import handleMeetingEnd from './handlers/meetingEnd';
import handleMeetingDestruction from './handlers/meetingDestruction';
import handleMeetingLocksChange from './handlers/meetingLockChange';
import handleGuestPolicyChanged from './handlers/guestPolicyChanged';
import handleGuestLobbyMessageChanged from './handlers/guestLobbyMessageChanged';
import handleUserLockChange from './handlers/userLockChange';
import handleRecordingStatusChange from './handlers/recordingStatusChange';
import handleRecordingTimerChange from './handlers/recordingTimerChange';
@ -21,5 +23,7 @@ RedisPubSub.on('RecordingStatusChangedEvtMsg', handleRecordingStatusChange);
RedisPubSub.on('UpdateRecordingTimerEvtMsg', handleRecordingTimerChange);
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
RedisPubSub.on('GetLockSettingsRespMsg', handleMeetingLocksChange);
RedisPubSub.on('GuestPolicyChangedEvtMsg', handleGuestPolicyChanged);
RedisPubSub.on('GuestLobbyMessageChangedEvtMsg', handleGuestLobbyMessageChanged);
RedisPubSub.on('MeetingTimeRemainingUpdateEvtMsg', handleTimeRemainingUpdate);
RedisPubSub.on('SelectRandomViewerRespMsg', handleSelectRandomViewer);

View File

@ -0,0 +1,11 @@
import setGuestLobbyMessage from '../modifiers/setGuestLobbyMessage';
import { check } from 'meteor/check';
export default function handleGuestLobbyMessageChanged({ body }, meetingId) {
const { message } = body;
check(meetingId, String);
check(message, String);
return setGuestLobbyMessage(meetingId, message);
}

View File

@ -0,0 +1,12 @@
import setGuestPolicy from '../modifiers/setGuestPolicy';
import { check } from 'meteor/check';
export default function handleGuestPolicyChanged({ body }, meetingId) {
const { policy } = body;
check(meetingId, String);
check(policy, String);
return setGuestPolicy(meetingId, policy);
}

View File

@ -4,11 +4,15 @@ import Meetings from '/imports/api/meetings';
import Breakouts from '/imports/api/breakouts';
import Logger from '/imports/startup/server/logger';
export default function handleMeetingEnd({ body }) {
export default function handleMeetingEnd({ header, body }) {
check(body, Object);
const { meetingId } = body;
check(meetingId, String);
check(header, Object);
const { userId } = header;
check(userId, String);
const cb = (err, num, meetingType) => {
if (err) {
Logger.error(`${meetingType} ending error: ${err}`);
@ -20,7 +24,7 @@ export default function handleMeetingEnd({ body }) {
};
Meetings.update({ meetingId },
{ $set: { meetingEnded: true } },
{ $set: { meetingEnded: true, meetingEndedBy: userId } },
(err, num) => { cb(err, num, 'Meeting'); });
Breakouts.update({ parentMeetingId: meetingId },

View File

@ -148,6 +148,7 @@ export default function addMeeting(meeting) {
meetingId,
meetingEnded,
publishedPoll: false,
guestLobbyMessage: '',
randomlySelectedUser: '',
}, flat(newMeeting, {
safe: true,

View File

@ -0,0 +1,28 @@
import Meetings from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
export default function setGuestLobbyMessage(meetingId, guestLobbyMessage) {
check(meetingId, String);
check(guestLobbyMessage, String);
const selector = {
meetingId,
};
const modifier = {
$set: {
guestLobbyMessage,
},
};
try {
const { numberAffected } = Meetings.upsert(selector, modifier);
if (numberAffected) {
Logger.verbose(`Set guest lobby message meetingId=${meetingId} guestLobbyMessage=${guestLobbyMessage}`);
}
} catch (err) {
Logger.error(`Setting guest lobby message: ${err}`);
}
}

View File

@ -0,0 +1,28 @@
import Meetings from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
export default function setGuestPolicy(meetingId, guestPolicy) {
check(meetingId, String);
check(guestPolicy, String);
const selector = {
meetingId,
};
const modifier = {
$set: {
'usersProp.guestPolicy': guestPolicy,
},
};
try {
const { numberAffected } = Meetings.upsert(selector, modifier);
if (numberAffected) {
Logger.verbose(`Set guest policy meetingId=${meetingId} guestPolicy=${guestPolicy}`);
}
} catch (err) {
Logger.error(`Setting guest policy: ${err}`);
}
}

View File

@ -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,
}

View File

@ -1,236 +1,297 @@
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, screenShareEndAlert } from '/imports/ui/components/screenshare/service';
import { SCREENSHARING_ERRORS } from './errors';
const SFU_CONFIG = Meteor.settings.public.kurento;
const SFU_URL = SFU_CONFIG.wsUrl;
const 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);
this.broker.onended = this.handleEnded.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;
if (webRtcPeer) {
const stream = webRtcPeer.getLocalStream();
KurentoScreenshareBridge.screenshareElementLoadAndPlay(stream, screenshareMediaElement, true);
}
handleEnded() {
screenShareEndAlert();
}
async kurentoViewScreen() {
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
let iceServers = [];
let started = false;
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;
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 onerror = (error) => {
const normalizedError = this.handleBrokerFailure(error);
if (this.maxConnectionAttemptsReached()) {
this.clearReconnectionTimeout();
this.connectionAttempts = 0;
onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
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.onended = this.handleEnded.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();
}
}

View File

@ -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,
};

View File

@ -18,9 +18,9 @@ export default function userLeaving(meetingId, userId, connectionId) {
userId,
};
const User = Users.findOne(selector);
const user = Users.findOne(selector);
if (!User) {
if (!user) {
Logger.info(`Skipping userLeaving. Could not find ${userId} in ${meetingId}`);
return;
}
@ -32,13 +32,14 @@ export default function userLeaving(meetingId, userId, connectionId) {
// If the current user connection is not the same that triggered the leave we skip
if (auth?.connectionId !== connectionId) {
Logger.info(`Skipping userLeaving. User connectionId=${User.connectionId} is different from requester connectionId=${connectionId}`);
Logger.info(`Skipping userLeaving. User connectionId=${user.connectionId} is different from requester connectionId=${connectionId}`);
return false;
}
const payload = {
userId,
sessionId: meetingId,
loggedOut: user.loggedOut,
};
ClientConnections.removeClientConnection(`${meetingId}--${userId}`, connectionId);

View File

@ -20,6 +20,6 @@ export default function userLeftMeeting() { // TODO-- spread the code to method/
ClientConnections.removeClientConnection(this.userId, this.connection.id);
}
} catch (err) {
Logger.error(`leaving dummy user to collection: ${err}`);
Logger.error(`Error on user left: ${err}`);
}
}

View File

@ -82,10 +82,8 @@ class Base extends Component {
const {
userID: localUserId,
credentials,
} = Auth;
const { meetingId } = credentials;
if (animations) HTML.classList.add('animationsEnabled');
if (!animations) HTML.classList.add('animationsDisabled');
@ -101,13 +99,12 @@ class Base extends Component {
}, { fields: { name: 1, userId: 1 } }
);
this.usersAlreadyInMeetingAtBeginning =
users && (typeof users.map === 'function') ?
users.map(user => user.userId)
: [];
users.observe({
added: (user) => {
const subscriptionsReady = Session.get('subscriptionsReady');
if (!subscriptionsReady) return;
const {
userJoinAudioAlerts,
userJoinPushAlerts,
@ -115,27 +112,25 @@ class Base extends Component {
if (!userJoinAudioAlerts && !userJoinPushAlerts) return;
if (!this.usersAlreadyInMeetingAtBeginning.includes(user.userId)) {
if (userJoinAudioAlerts) {
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename
+ Meteor.settings.public.app.instanceId}`
+ '/resources/sounds/userJoin.mp3');
}
if (userJoinAudioAlerts) {
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename
+ Meteor.settings.public.app.instanceId}`
+ '/resources/sounds/userJoin.mp3');
}
if (userJoinPushAlerts) {
notify(
<FormattedMessage
id="app.notification.userJoinPushAlert"
description="Notification for a user joins the meeting"
values={{
0: user.name,
}}
/>,
'info',
'user',
);
}
if (userJoinPushAlerts) {
notify(
<FormattedMessage
id="app.notification.userJoinPushAlert"
description="Notification for a user joins the meeting"
values={{
0: user.name,
}}
/>,
'info',
'user',
);
}
}
});

View File

@ -63,6 +63,7 @@ class IntlStartup extends Component {
fetchLocalizedMessages(locale, init = false) {
const url = `./locale?locale=${locale}&init=${init}`;
const localesPath = 'locales';
this.setState({ fetching: true }, () => {
fetch(url)
@ -73,11 +74,65 @@ class IntlStartup extends Component {
return response.json();
})
.then(({ messages, normalizedLocale }) => {
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ messages, fetching: false, normalizedLocale: dasherizedLocale }, () => {
IntlStartup.saveLocale(dasherizedLocale);
});
.then(({ normalizedLocale, regionDefaultLocale }) => {
fetch(`${localesPath}/${DEFAULT_LANGUAGE}.json`)
.then((response) => {
if (!response.ok) {
return Promise.reject();
}
return response.json();
})
.then((messages) => {
if (regionDefaultLocale !== '') {
fetch(`${localesPath}/${regionDefaultLocale}.json`)
.then((response) => {
if (!response.ok) {
return Promise.resolve();
}
return response.json();
})
.then((regionDefaultMessages) => {
messages = Object.assign(messages, regionDefaultMessages);
return messages;
});
}
if (normalizedLocale !== DEFAULT_LANGUAGE && normalizedLocale !== regionDefaultLocale) {
fetch(`${localesPath}/${normalizedLocale}.json`)
.then((response) => {
if (!response.ok) {
return Promise.reject();
}
return response.json();
})
.then((localeMessages) => {
messages = Object.assign(messages, localeMessages);
return messages;
})
.catch(() => {
normalizedLocale = (regionDefaultLocale) || DEFAULT_LANGUAGE;
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ messages, fetching: false, normalizedLocale: dasherizedLocale }, () => {
IntlStartup.saveLocale(normalizedLocale);
});
});
}
return messages;
})
.then((messages) => {
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ messages, fetching: false, normalizedLocale: dasherizedLocale }, () => {
IntlStartup.saveLocale(dasherizedLocale);
});
})
.catch(() => {
normalizedLocale = DEFAULT_LANGUAGE;
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ fetching: false, normalizedLocale: dasherizedLocale }, () => {
IntlStartup.saveLocale(normalizedLocale);
});
});
})
.catch(() => {
this.setState({ fetching: false, normalizedLocale: null }, () => {

View File

@ -12,7 +12,16 @@ import Redis from './redis';
import setMinBrowserVersions from './minBrowserVersion';
let guestWaitHtml = '';
const AVAILABLE_LOCALES = fs.readdirSync('assets/app/locales');
const env = Meteor.isDevelopment ? 'development' : 'production';
const meteorRoot = fs.realpathSync(`${process.cwd()}/../`);
const applicationRoot = (env === 'development')
? fs.realpathSync(`${meteorRoot}'/../../../../public/locales/`)
: fs.realpathSync(`${meteorRoot}/../programs/web.browser/app/locales/`);
const AVAILABLE_LOCALES = fs.readdirSync(`${applicationRoot}`);
const FALLBACK_LOCALES = JSON.parse(Assets.getText('config/fallbackLocales.json'));
process.on('uncaughtException', (err) => {
@ -27,7 +36,6 @@ process.on('uncaughtException', (err) => {
Meteor.startup(() => {
const APP_CONFIG = Meteor.settings.public.app;
const env = Meteor.isDevelopment ? 'development' : 'production';
const CDN_URL = APP_CONFIG.cdn;
const instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1;
@ -106,40 +114,39 @@ Meteor.startup(() => {
session.bbbFixApplied = true;
}
}, 5000);
if (CDN_URL.trim()) {
// Add CDN
BrowserPolicy.content.disallowEval();
BrowserPolicy.content.allowInlineScripts();
BrowserPolicy.content.allowInlineStyles();
BrowserPolicy.content.allowImageDataUrl(CDN_URL);
BrowserPolicy.content.allowFontDataUrl(CDN_URL);
BrowserPolicy.content.allowOriginForAll(CDN_URL);
WebAppInternals.setBundledJsCssPrefix(CDN_URL + APP_CONFIG.basename + Meteor.settings.public.app.instanceId);
const fontRegExp = /\.(eot|ttf|otf|woff|woff2)$/;
WebApp.rawConnectHandlers.use('/', (req, res, next) => {
if (fontRegExp.test(req._parsedUrl.pathname)) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Vary', 'Origin');
res.setHeader('Pragma', 'public');
res.setHeader('Cache-Control', '"public"');
}
return next();
});
}
setMinBrowserVersions();
Logger.warn(`SERVER STARTED.
ENV=${env}
nodejs version=${process.version}
BBB_HTML5_ROLE=${process.env.BBB_HTML5_ROLE}
INSTANCE_ID=${instanceId}
PORT=${process.env.PORT}
CDN=${CDN_URL}\n`, APP_CONFIG);
}
if (CDN_URL.trim()) {
// Add CDN
BrowserPolicy.content.disallowEval();
BrowserPolicy.content.allowInlineScripts();
BrowserPolicy.content.allowInlineStyles();
BrowserPolicy.content.allowImageDataUrl(CDN_URL);
BrowserPolicy.content.allowFontDataUrl(CDN_URL);
BrowserPolicy.content.allowOriginForAll(CDN_URL);
WebAppInternals.setBundledJsCssPrefix(CDN_URL + APP_CONFIG.basename + Meteor.settings.public.app.instanceId);
const fontRegExp = /\.(eot|ttf|otf|woff|woff2)$/;
WebApp.rawConnectHandlers.use('/', (req, res, next) => {
if (fontRegExp.test(req._parsedUrl.pathname)) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Vary', 'Origin');
res.setHeader('Pragma', 'public');
res.setHeader('Cache-Control', '"public"');
}
return next();
});
}
setMinBrowserVersions();
Logger.warn(`SERVER STARTED.
ENV=${env}
nodejs version=${process.version}
BBB_HTML5_ROLE=${process.env.BBB_HTML5_ROLE}
INSTANCE_ID=${instanceId}
PORT=${process.env.PORT}
CDN=${CDN_URL}\n`, APP_CONFIG);
});
@ -192,7 +199,7 @@ WebApp.connectHandlers.use('/locale', (req, res) => {
const browserLocale = override && req.query.init === 'true'
? override.split(/[-_]/g) : req.query.locale.split(/[-_]/g);
const localeList = [fallback];
let localeFile = fallback;
const usableLocales = AVAILABLE_LOCALES
.map(file => file.replace('.json', ''))
@ -200,35 +207,29 @@ WebApp.connectHandlers.use('/locale', (req, res) => {
? [...locales, locale]
: locales), []);
const regionDefault = usableLocales.find(locale => browserLocale[0] === locale);
if (regionDefault) localeList.push(regionDefault);
if (!regionDefault && usableLocales.length) localeList.push(usableLocales[0]);
let normalizedLocale;
let messages = {};
if (browserLocale.length > 1) {
normalizedLocale = `${browserLocale[0]}_${browserLocale[1].toUpperCase()}`;
localeList.push(normalizedLocale);
const normDefault = usableLocales.find(locale => normalizedLocale === locale);
if (normDefault) localeFile = normDefault;
}
localeList.forEach((locale) => {
try {
const data = Assets.getText(`locales/${locale}.json`);
messages = Object.assign(messages, JSON.parse(data));
normalizedLocale = locale;
} catch (e) {
Logger.info(`'Could not process locale ${locale}:${e}`);
// Getting here means the locale is not available in the current locale files.
}
});
const regionDefault = usableLocales.find(locale => browserLocale[0] === locale);
if (localeFile === fallback && regionDefault !== localeFile) {
localeFile = regionDefault;
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ normalizedLocale, messages }));
res.end(JSON.stringify({
normalizedLocale: localeFile,
regionDefaultLocale: (regionDefault && regionDefault !== localeFile) ? regionDefault : '',
}));
});
WebApp.connectHandlers.use('/locales', (req, res) => {
WebApp.connectHandlers.use('/locale-list', (req, res) => {
if (!avaibleLocalesNamesJSON) {
avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
}

View File

@ -1,8 +1,8 @@
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 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';
@ -13,23 +13,18 @@ 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;
@ -74,20 +69,14 @@ class ActionsBar extends PureComponent {
<JoinVideoOptionsContainer />
)
: null}
<DesktopShare {...{
handleShareScreen,
handleUnshareScreen,
isVideoBroadcasting,
<ScreenshareButtonContainer {...{
amIPresenter,
screenSharingCheck,
screenShareEndAlert,
isMeteorConnected,
screenshareDataSavingSetting,
}}
/>
</div>
<div className={styles.right}>
{isLayoutSwapped
{isLayoutSwapped && !isPresentationDisabled
? (
<PresentationOptionsContainer
toggleSwapLayout={toggleSwapLayout}

View File

@ -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,
@ -25,15 +21,12 @@ import MediaService, {
const ActionsBarContainer = props => <ActionsBar {...props} />;
const POLLING_ENABLED = Meteor.settings.public.poll.enabled;
const PRESENTATION_DISABLED = Meteor.settings.public.layout.hidePresentation;
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,
@ -41,11 +34,10 @@ 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,
isPresentationDisabled: PRESENTATION_DISABLED,
isThereCurrentPresentation: Presentations.findOne({ meetingId: Auth.meetingID, current: true },
{ fields: {} }),
allowExternalVideo: Meteor.settings.public.externalVideoPlayer.enabled,

View File

@ -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)));

View 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)));

View File

@ -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));

View File

@ -161,11 +161,13 @@ class AudioModal extends Component {
componentDidMount() {
const {
forceListenOnlyAttendee,
joinFullAudioImmediately,
listenOnlyMode,
audioLocked,
} = this.props;
if (forceListenOnlyAttendee) return this.handleJoinListenOnly();
if (audioLocked) return this.handleJoinMicrophone();
if ((joinFullAudioImmediately && !listenOnlyMode) || audioLocked) return this.handleJoinMicrophone();
}
componentDidUpdate(prevProps) {

View File

@ -6,6 +6,8 @@ import logger from '/imports/startup/client/logger';
import PropTypes from 'prop-types';
import AudioService from '../audio/service';
import VideoService from '../video-provider/service';
import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
import UserListService from '/imports/ui/components/user-list/service';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -108,7 +110,7 @@ class BreakoutJoinConfirmation extends Component {
}
VideoService.exitVideo();
window.kurentoExitScreenShare();
if (UserListService.amIPresenter()) screenshareHasEnded();
if (url === '') {
logger.error({
logCode: 'breakoutjoinconfirmation_redirecting_to_url',

View File

@ -7,6 +7,8 @@ import logger from '/imports/startup/client/logger';
import { styles } from './styles';
import BreakoutRoomContainer from './breakout-remaining-time/container';
import VideoService from '/imports/ui/components/video-provider/service';
import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
import UserListService from '/imports/ui/components/user-list/service';
const intlMessages = defineMessages({
breakoutTitle: {
@ -224,7 +226,7 @@ class BreakoutRoom extends PureComponent {
extraInfo: { logType: 'user_action' },
}, 'joining breakout room closed audio in the main room');
VideoService.exitVideo();
window.kurentoExitScreenShare();
if (UserListService.amIPresenter()) screenshareHasEnded();
}
}
disabled={disable}

View File

@ -43,6 +43,7 @@ const getAvailableLocales = () => {
const { meetingID } = Auth;
const locales = [];
Captions.find({ meetingId: meetingID },
{ sort: { locale: 1 } },
{ fields: { ownerId: 1, locale: 1 } })
.forEach((caption) => {
if (caption.ownerId === '') {

View File

@ -5,6 +5,7 @@ import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrap
import Button from '/imports/ui/components/button/component';
import { Session } from 'meteor/session';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import { styles } from './styles.scss';
import MessageForm from './message-form/container';
import TimeWindowList from './time-window-list/container';
@ -45,9 +46,12 @@ const Chat = (props) => {
timeWindowsValues,
dispatch,
count,
syncing,
syncedPercent,
} = props;
const HIDE_CHAT_AK = shortcuts.hidePrivateChat;
const CLOSE_CHAT_AK = shortcuts.closePrivateChat;
ChatLogger.debug('ChatComponent::render', props);
return (
<div
data-test={chatID !== 'public' ? 'privateChat' : 'publicChat'}
@ -113,6 +117,8 @@ const Chat = (props) => {
timeWindowsValues,
dispatch,
count,
syncing,
syncedPercent,
}}
/>
<MessageForm

View File

@ -23,7 +23,8 @@ const DEBOUNCE_TIME = 1000;
const sysMessagesIds = {
welcomeId: `${SYSTEM_CHAT_TYPE}-welcome-msg`,
moderatorId: `${SYSTEM_CHAT_TYPE}-moderator-msg`
moderatorId: `${SYSTEM_CHAT_TYPE}-moderator-msg`,
syncId: `${SYSTEM_CHAT_TYPE}-sync-msg`
};
const intlMessages = defineMessages({
@ -43,11 +44,15 @@ const intlMessages = defineMessages({
id: 'app.chat.partnerDisconnected',
description: 'System chat message when the private chat partnet disconnect from the meeting',
},
loading: {
id: 'app.chat.loading',
description: 'loading message',
},
});
let previousChatId = null;
let debounceTimeout = null;
let messages = null;
let prevSync = false;
let globalAppplyStateToProps = () => { }
const throttledFunc = _.throttle(() => {
@ -71,6 +76,8 @@ const ChatContainer = (props) => {
intl,
} = props;
ChatLogger.debug('ChatContainer::render::props', props);
const isPublicChat = chatID === PUBLIC_CHAT_KEY;
const systemMessages = {
[sysMessagesIds.welcomeId]: {
@ -118,10 +125,33 @@ const ChatContainer = (props) => {
const lastMsg = contextChat && (isPublicChat
? contextChat.preJoinMessages[lastTimeWindow] || contextChat.posJoinMessages[lastTimeWindow]
: contextChat.messageGroups[lastTimeWindow]);
ChatLogger.debug('ChatContainer::render::chatData',contextChat);
applyPropsToState = () => {
if (!_.isEqualWith(lastMsg, stateLastMsg) || previousChatId !== chatID) {
ChatLogger.debug('ChatContainer::applyPropsToState::chatData',lastMsg, stateLastMsg, contextChat?.syncing);
if (
(lastMsg?.lastTimestamp !== stateLastMsg?.lastTimestamp)
|| (previousChatId !== chatID)
|| (prevSync !== contextChat?.syncing)
) {
prevSync = contextChat?.syncing;
const timeWindowsValues = isPublicChat
? [...Object.values(contextChat?.preJoinMessages || {}), ...systemMessagesIds.map((item) => systemMessages[item]),
? [
...(
!contextChat?.syncing ? Object.values(contextChat?.preJoinMessages || {}) : [
{
id: sysMessagesIds.syncId,
content: [{
id: 'synced',
text: intl.formatMessage(intlMessages.loading, { 0: contextChat?.syncedPercent}),
time: loginTime + 1,
}],
key: sysMessagesIds.syncId,
time: loginTime + 1,
sender: null,
}
]
)
, ...systemMessagesIds.map((item) => systemMessages[item]),
...Object.values(contextChat?.posJoinMessages || {})]
: [...Object.values(contextChat?.messageGroups || {})];
if (previousChatId !== chatID) {
@ -144,6 +174,8 @@ const ChatContainer = (props) => {
timeWindowsValues: stateTimeWindows,
dispatch: usingChatContext?.dispatch,
title,
syncing: contextChat?.syncing,
syncedPercent: contextChat?.syncedPercent,
chatName,
contextChat,
}}>

View File

@ -93,9 +93,16 @@ class TimeWindowList extends PureComponent {
setUserSentMessage,
timeWindowsValues,
chatId,
syncing,
syncedPercent,
} = this.props;
const {timeWindowsValues: prevTimeWindowsValues, chatId: prevChatId} = prevProps;
const {
timeWindowsValues: prevTimeWindowsValues,
chatId: prevChatId,
syncing: prevSyncing,
syncedPercent: prevSyncedPercent
} = prevProps;
const prevTimeWindowsLength = prevTimeWindowsValues.length;
const timeWindowsValuesLength = timeWindowsValues.length;
@ -109,15 +116,23 @@ class TimeWindowList extends PureComponent {
}
}
if (lastTimeWindow && (chatId !== prevChatId)) {
this.listRef.recomputeGridSize();
}
if (userSentMessage && !prevProps.userSentMessage){
this.setState({
userScrolledBack: false,
}, ()=> setUserSentMessage(false));
}
// this condition exist to the case where the chat has a single message and the chat is cleared
// The component List from react-virtualized doesn't have a reference to the list of messages so I need force the update to fix it
if (
(lastTimeWindow?.id === 'SYSTEM_MESSAGE-PUBLIC_CHAT_CLEAR')
|| (prevSyncing && !syncing)
|| (syncedPercent !== prevSyncedPercent)
|| (chatId !== prevChatId)
) {
this.listRef.forceUpdateGrid();
}
}
componentWillUnmount() {

View File

@ -1,24 +1,83 @@
import { useContext, useEffect } from 'react';
import { useContext, useEffect, useState } from 'react';
import _ from 'lodash';
import { ChatContext, ACTIONS } from './context';
import { UsersContext } from '../users-context/context';
import { makeCall } from '/imports/ui/services/api';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
let usersData = {};
let messageQueue = [];
const CHAT_CONFIG = Meteor.settings.public.chat;
const ITENS_PER_PAGE = CHAT_CONFIG.itemsPerPage;
const TIME_BETWEEN_FETCHS = CHAT_CONFIG.timeBetweenFetchs;
const getMessagesBeforeJoinCounter = async () => {
const counter = await makeCall('chatMessageBeforeJoinCounter');
return counter;
};
const startSyncMessagesbeforeJoin = async (dispatch) => {
const chatsMessagesCount = await getMessagesBeforeJoinCounter();
const pagesPerChat = chatsMessagesCount.map(chat => ({ ...chat, pages: Math.ceil(chat.count / ITENS_PER_PAGE), syncedPages: 0 }));
const syncRoutine = async (chatsToSync) => {
if (!chatsToSync.length) return;
const pagesToFetch = [...chatsToSync].sort((a, b) => a.pages - b.pages);
const chatWithLessPages = pagesToFetch[0];
chatWithLessPages.syncedPages += 1;
const messagesFromPage = await makeCall('fetchMessagePerPage', chatWithLessPages.chatId, chatWithLessPages.syncedPages);
if (messagesFromPage.length) {
dispatch({
type: ACTIONS.ADDED,
value: messagesFromPage,
});
dispatch({
type: ACTIONS.SYNC_STATUS,
value: {
chatId: chatWithLessPages.chatId,
percentage: Math.floor((chatWithLessPages.syncedPages / chatWithLessPages.pages) * 100),
},
});
}
await new Promise(r => setTimeout(r, TIME_BETWEEN_FETCHS));
syncRoutine(pagesToFetch.filter(chat => !(chat.syncedPages > chat.pages)));
};
syncRoutine(pagesPerChat);
};
const Adapter = () => {
const usingChatContext = useContext(ChatContext);
const { dispatch } = usingChatContext;
const usingUsersContext = useContext(UsersContext);
const { users } = usingUsersContext;
const [syncStarted, setSync] = useState(true);
ChatLogger.trace('chatAdapter::body::users', users);
useEffect(() => {
const connectionStatus = Meteor.status();
if (connectionStatus.connected && !syncStarted) {
setSync(true);
startSyncMessagesbeforeJoin(dispatch);
}
}, [Meteor.status().connected, syncStarted]);
useEffect(() => {
usersData = users;
}, [usingUsersContext]);
useEffect(() => {
// TODO: listen to websocket message to avoid full list comparsion
if (!Meteor.status().connected) return;
setSync(false);
dispatch({
type: ACTIONS.CLEAR_ALL,
});
const throttledDispatch = _.throttle(() => {
const dispatchedMessageQueue = [...messageQueue];
messageQueue = [];
@ -32,9 +91,7 @@ const Adapter = () => {
if (msg.data.indexOf('{"msg":"added","collection":"group-chat-msg"') != -1) {
const parsedMsg = JSON.parse(msg.data);
if (parsedMsg.msg === 'added') {
messageQueue.push({
msg: parsedMsg.fields,
});
messageQueue.push(parsedMsg.fields);
throttledDispatch();
}
}
@ -44,7 +101,7 @@ const Adapter = () => {
});
}
});
}, []);
}, [Meteor.status().connected]);
return null;
};

View File

@ -22,13 +22,16 @@ export const ACTIONS = {
REMOVED: 'removed',
LAST_READ_MESSAGE_TIMESTAMP_CHANGED: 'last_read_message_timestamp_changed',
INIT: 'initial_structure',
SYNC_STATUS: 'sync_status',
HAS_MESSAGE_TO_SYNC: 'has_message_to_sync',
CLEAR_ALL: 'clear_all',
};
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
export const getGroupingTime = () => Meteor.settings.public.chat.grouping_messages_window;
export const getGroupChatId = () => Meteor.settings.public.chat.public_group_id;
export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {}).loginTime || 0;
export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {}).authTokenValidatedTime || 0;
const generateTimeWindow = (timestamp) => {
const groupingTime = getGroupingTime();
@ -40,12 +43,12 @@ const generateTimeWindow = (timestamp) => {
export const ChatContext = createContext();
const generateStateWithNewMessage = ({ msg, senderData }, state) => {
const generateStateWithNewMessage = (msg, state) => {
const timeWindow = generateTimeWindow(msg.timestamp);
const userId = msg.sender.id;
const keyName = userId + '-' + timeWindow;
const msgBuilder = ({msg, senderData}, chat) => {
const msgBuilder = (msg, chat) => {
const msgTimewindow = generateTimeWindow(msg.timestamp);
const key = msg.sender.id + '-' + msgTimewindow;
const chatIndex = chat?.chatIndexes[key];
@ -80,6 +83,7 @@ const generateStateWithNewMessage = ({ msg, senderData }, state) => {
chatIndexes: {},
preJoinMessages: {},
posJoinMessages: {},
synced:true,
unreadTimeWindows: new Set(),
unreadCount: 0,
};
@ -87,6 +91,7 @@ const generateStateWithNewMessage = ({ msg, senderData }, state) => {
state[msg.chatId] = {
count: 0,
lastSender: '',
synced:true,
chatIndexes: {},
messageGroups: {},
unreadTimeWindows: new Set(),
@ -106,7 +111,7 @@ const generateStateWithNewMessage = ({ msg, senderData }, state) => {
if (!groupMessage || (groupMessage && groupMessage.sender.id !== stateMessages.lastSender.id)) {
const [tempGroupMessage, sender, newIndex] = msgBuilder({msg, senderData}, stateMessages);
const [tempGroupMessage, sender, newIndex] = msgBuilder(msg, stateMessages);
stateMessages.lastSender = sender;
stateMessages.chatIndexes[keyName] = newIndex;
stateMessages.lastTimewindow = keyName + '-' + newIndex;
@ -116,7 +121,8 @@ const generateStateWithNewMessage = ({ msg, senderData }, state) => {
messageGroupsKeys.forEach(key => {
messageGroups[key] = tempGroupMessage[key];
const message = tempGroupMessage[key];
if (message.sender.id !== Auth.userID && !message.id.startsWith(SYSTEM_CHAT_TYPE)) {
const previousMessage = message.timestamp <= getLoginTime();
if (!previousMessage && message.sender.id !== Auth.userID && !message.id.startsWith(SYSTEM_CHAT_TYPE)) {
stateMessages.unreadTimeWindows.add(key);
}
});
@ -161,7 +167,7 @@ const reducer = (state, action) => {
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
const loginTime = getLoginTime();
const newState = batchMsgs.reduce((acc, i)=> {
const message = i.msg;
const message = i;
const chatId = message.chatId;
if (
chatId !== PUBLIC_GROUP_CHAT_KEY
@ -169,8 +175,7 @@ const reducer = (state, action) => {
&& currentClosedChats.includes(chatId) ){
closedChatsToOpen.add(chatId)
}
return generateStateWithNewMessage(i, acc);
return generateStateWithNewMessage(message, acc);
}, state);
if (closedChatsToOpen.size) {
@ -193,6 +198,7 @@ const reducer = (state, action) => {
count: 0,
lastSender: '',
chatIndexes: {},
syncing: false,
preJoinMessages: {},
posJoinMessages: {},
unreadTimeWindows: new Set(),
@ -260,6 +266,45 @@ const reducer = (state, action) => {
}
return state;
}
case ACTIONS.SYNC_STATUS: {
ChatLogger.debug(ACTIONS.SYNC_STATUS);
const newState = { ...state };
newState[action.value.chatId].syncedPercent = action.value.percentage;
newState[action.value.chatId].syncing = action.value.percentage < 100 ? true : false;
return newState;
}
case ACTIONS.CLEAR_ALL: {
ChatLogger.debug(ACTIONS.CLEAR_ALL);
const newState = { ...state };
const chatIds = Object.keys(newState);
chatIds.forEach((chatId) => {
newState[chatId] = chatId === PUBLIC_GROUP_CHAT_KEY ?
{
count: 0,
lastSender: '',
chatIndexes: {},
preJoinMessages: {},
posJoinMessages: {},
syncing: false,
syncedPercent: 0,
unreadTimeWindows: new Set(),
unreadCount: 0,
}
:
{
count: 0,
lastSender: '',
chatIndexes: {},
messageGroups: {},
syncing: false,
syncedPercent: 0,
unreadTimeWindows: new Set(),
unreadCount: 0,
};
});
return newState;
}
default: {
throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
}
@ -293,4 +338,4 @@ export const ContextConsumer = Component => props => (
export default {
ContextConsumer,
ChatContextProvider,
}
}

View File

@ -12,6 +12,10 @@ const intlMessages = defineMessages({
},
endMeetingDescription: {
id: 'app.endMeeting.description',
description: 'end meeting description with affected users information',
},
endMeetingNoUserDescription: {
id: 'app.endMeeting.noUserDescription',
description: 'end meeting description',
},
yesLabel: {
@ -50,7 +54,10 @@ class EndMeetingComponent extends React.PureComponent {
>
<div className={styles.container}>
<div className={styles.description}>
{intl.formatMessage(intlMessages.endMeetingDescription, { 0: users })}
{users > 0
? intl.formatMessage(intlMessages.endMeetingDescription, { 0: users })
: intl.formatMessage(intlMessages.endMeetingNoUserDescription)
}
</div>
<div className={styles.footer}>
<Button

View File

@ -13,6 +13,7 @@ const getMeetingTitle = () => {
const getUsers = () => {
const numUsers = Users.find({
meetingId: Auth.meetingID,
userId: { $ne: Auth.userID },
}, { fields: { _id: 1 } }).count();
return numUsers;

View File

@ -19,6 +19,8 @@ const POLL_MIN_WIDTH = 320;
const POLL_MAX_WIDTH = 400;
const NOTE_MIN_WIDTH = 340;
const NOTE_MAX_WIDTH = 800;
const WAITING_MIN_WIDTH = 340;
const WAITING_MAX_WIDTH = 800;
const NAVBAR_HEIGHT = 85;
const ACTIONSBAR_HEIGHT = 42;
@ -184,6 +186,22 @@ class LayoutManager extends Component {
},
},
);
layoutContextDispatch(
{
type: 'setCaptionsSize',
value: {
width: layoutSizes.captionsSize.width,
},
},
);
layoutContextDispatch(
{
type: 'setWaitingUsersPanelSize',
value: {
width: layoutSizes.waitingSize.width,
},
},
);
layoutContextDispatch(
{
type: 'setBreakoutRoomSize',
@ -234,6 +252,12 @@ class LayoutManager extends Component {
noteSize: {
width: layoutSizes.noteSize.width,
},
captionsSize: {
width: layoutSizes.captionsSize.width,
},
waitingSize: {
width: layoutSizes.waitingSize.width,
},
breakoutRoomSize: {
width: layoutSizes.breakoutRoomSize.width,
},
@ -295,6 +319,8 @@ class LayoutManager extends Component {
chatSize: chatSizeContext,
pollSize: pollSizeContext,
noteSize: noteSizeContext,
captionsSize: captionsSizeContext,
waitingSize: waitingSizeContext,
breakoutRoomSize: breakoutRoomSizeContext,
} = layoutContextState;
const openPanel = Session.get('openPanel');
@ -304,20 +330,26 @@ class LayoutManager extends Component {
let storageChatWidth;
let storagePollWidth;
let storageNoteWidth;
let storageCaptionsWidth;
let storageWaitingWidth;
let storageBreakoutRoomWidth;
if (storageLData) {
storageUserListWidth = storageLData.userListSize.width;
storageChatWidth = storageLData.chatSize.width;
storagePollWidth = storageLData.pollSize.width;
storageNoteWidth = storageLData.noteSize.width;
storageBreakoutRoomWidth = storageLData.breakoutRoomSize.width;
storageUserListWidth = storageLData.userListSize?.width;
storageChatWidth = storageLData.chatSize?.width;
storagePollWidth = storageLData.pollSize?.width;
storageNoteWidth = storageLData.noteSize?.width;
storageCaptionsWidth = storageLData.captionsSize?.width;
storageWaitingWidth = storageLData.waitingSize?.width;
storageBreakoutRoomWidth = storageLData.breakoutRoomSize?.width;
}
let newUserListSize;
let newChatSize;
let newPollSize;
let newNoteSize;
let newCaptionsSize;
let newWaitingSize;
let newBreakoutRoomSize;
if (panelChanged && userListSizeContext.width !== 0) {
@ -368,6 +400,30 @@ class LayoutManager extends Component {
};
}
if (panelChanged && captionsSizeContext.width !== 0) {
newCaptionsSize = captionsSizeContext;
} else if (!storageCaptionsWidth) {
newCaptionsSize = {
width: min(max((windowWidth() * 0.2), NOTE_MIN_WIDTH), NOTE_MAX_WIDTH),
};
} else {
newCaptionsSize = {
width: storageCaptionsWidth,
};
}
if (panelChanged && waitingSizeContext.width !== 0) {
newWaitingSize = waitingSizeContext;
} else if (!storageWaitingWidth) {
newWaitingSize = {
width: min(max((windowWidth() * 0.2), WAITING_MIN_WIDTH), WAITING_MAX_WIDTH),
};
} else {
newWaitingSize = {
width: storageWaitingWidth,
};
}
if (panelChanged && breakoutRoomSizeContext.width !== 0) {
newBreakoutRoomSize = breakoutRoomSizeContext;
} else if (!storageBreakoutRoomWidth) {
@ -394,6 +450,12 @@ class LayoutManager extends Component {
newNoteSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case 'poll': {
@ -406,6 +468,12 @@ class LayoutManager extends Component {
newBreakoutRoomSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case 'note': {
@ -418,6 +486,48 @@ class LayoutManager extends Component {
newBreakoutRoomSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case 'captions': {
newChatSize = {
width: 0,
};
newPollSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
newNoteSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case 'waitingUsersPanel': {
newChatSize = {
width: 0,
};
newPollSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
newNoteSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
break;
}
case 'chat': {
@ -430,6 +540,12 @@ class LayoutManager extends Component {
newNoteSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case 'breakoutroom': {
@ -442,6 +558,12 @@ class LayoutManager extends Component {
newNoteSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
case '': {
@ -460,6 +582,12 @@ class LayoutManager extends Component {
newNoteSize = {
width: 0,
};
newCaptionsSize = {
width: 0,
};
newWaitingSize = {
width: 0,
};
break;
}
default: {
@ -472,6 +600,8 @@ class LayoutManager extends Component {
newChatSize,
newPollSize,
newNoteSize,
newCaptionsSize,
newWaitingSize,
newBreakoutRoomSize,
};
}
@ -600,6 +730,8 @@ class LayoutManager extends Component {
newChatSize,
newPollSize,
newNoteSize,
newCaptionsSize,
newWaitingSize,
newBreakoutRoomSize,
} = panelsSize;
@ -615,6 +747,10 @@ class LayoutManager extends Component {
secondPanel = newPollSize;
} else if (newNoteSize.width > 0) {
secondPanel = newNoteSize;
} else if (newCaptionsSize.width > 0) {
secondPanel = newCaptionsSize;
} else if (newWaitingSize.width > 0) {
secondPanel = newWaitingSize;
} else if (newBreakoutRoomSize.width > 0) {
secondPanel = newBreakoutRoomSize;
}
@ -667,6 +803,8 @@ class LayoutManager extends Component {
chatSize: newChatSize,
pollSize: newPollSize,
noteSize: newNoteSize,
captionsSize: newCaptionsSize,
waitingSize: newWaitingSize,
breakoutRoomSize: newBreakoutRoomSize,
webcamsAreaSize: newWebcamsAreaSize,
presentationAreaSize: newPresentationAreaSize,
@ -689,6 +827,8 @@ export {
POLL_MAX_WIDTH,
NOTE_MIN_WIDTH,
NOTE_MAX_WIDTH,
WAITING_MIN_WIDTH,
WAITING_MAX_WIDTH,
NAVBAR_HEIGHT,
ACTIONSBAR_HEIGHT,
WEBCAMSAREA_MIN_PERCENT,

View File

@ -69,16 +69,17 @@ const FETCHING = 'fetching';
const FALLBACK = 'fallback';
const READY = 'ready';
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge', 'yandex'];
const DEFAULT_LANGUAGE = Meteor.settings.public.app.defaultSettings.application.fallbackLocale;
export default class Legacy extends Component {
constructor(props) {
super(props);
const locale = navigator.languages ? navigator.languages[0] : false
|| navigator.language
|| Meteor.settings.public.app.defaultSettings.application.fallbackLocale;
|| navigator.language;
const url = `./locale?locale=${locale}`;
const localesPath = 'locales';
const that = this;
this.state = { viewState: FETCHING };
@ -90,9 +91,56 @@ export default class Legacy extends Component {
return response.json();
})
.then(({ messages, normalizedLocale }) => {
const dasherizedLocale = normalizedLocale.replace('_', '-');
that.setState({ messages, normalizedLocale: dasherizedLocale, viewState: READY });
.then(({ normalizedLocale, regionDefaultLocale }) => {
fetch(`${localesPath}/${DEFAULT_LANGUAGE}.json`)
.then((response) => {
if (!response.ok) {
return Promise.reject();
}
return response.json();
})
.then((messages) => {
if (regionDefaultLocale !== '') {
fetch(`${localesPath}/${regionDefaultLocale}.json`)
.then((response) => {
if (!response.ok) {
return Promise.resolve();
}
return response.json();
})
.then((regionDefaultMessages) => {
messages = Object.assign(messages, regionDefaultMessages);
this.setState({ messages});
});
}
if (normalizedLocale && normalizedLocale !== DEFAULT_LANGUAGE && normalizedLocale !== regionDefaultLocale) {
fetch(`${localesPath}/${normalizedLocale}.json`)
.then((response) => {
if (!response.ok) {
return Promise.reject();
}
return response.json();
})
.then((localeMessages) => {
messages = Object.assign(messages, localeMessages);
this.setState({ messages});
})
.catch(() => {
normalizedLocale = (regionDefaultLocale) || DEFAULT_LANGUAGE;
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ messages, normalizedLocale: dasherizedLocale, viewState: READY });
});
}
return messages;
})
.then((messages) => {
const dasherizedLocale = normalizedLocale.replace('_', '-');
this.setState({ messages, normalizedLocale: dasherizedLocale, viewState: READY });
})
.catch(() => {
that.setState({ viewState: FALLBACK });
});
})
.catch(() => {
that.setState({ viewState: FALLBACK });

View File

@ -45,6 +45,7 @@ export default class Media extends Component {
audioModalIsOpen,
usersVideo,
layoutContextState,
isMeteorConnected,
} = this.props;
const { webcamsPlacement: placement } = layoutContextState;
@ -67,7 +68,7 @@ export default class Media extends Component {
[styles.containerH]: webcamsPlacement === 'left' || webcamsPlacement === 'right',
});
const { viewParticipantsWebcams } = Settings.dataSaving;
const showVideo = usersVideo.length > 0 && viewParticipantsWebcams;
const showVideo = usersVideo.length > 0 && viewParticipantsWebcams && isMeteorConnected;
const fullHeight = !showVideo || (webcamsPlacement === 'floating');
return (

View File

@ -115,6 +115,7 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => {
const data = {
children: <DefaultContent {...{ autoSwapLayout, hidePresentation }} />,
audioModalIsOpen: Session.get('audioModalIsOpen'),
isMeteorConnected: Meteor.status().connected,
};
if (MediaService.shouldShowWhiteboard() && !hidePresentation) {

View File

@ -489,6 +489,7 @@ class WebcamDraggable extends PureComponent {
style={{
marginLeft: 0,
marginRight: 0,
zIndex: 2,
display: hideWebcams ? 'none' : undefined,
}}
>

View File

@ -11,6 +11,7 @@ import Rating from './rating/component';
import { styles } from './styles';
import logger from '/imports/startup/client/logger';
import Users from '/imports/api/users';
import Meetings from '/imports/api/meetings';
import AudioManager from '/imports/ui/services/audio-manager';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
@ -35,6 +36,10 @@ const intlMessage = defineMessages({
id: 'app.meeting.endedMessage',
description: 'message saying to go back to home screen',
},
messageEndedByUser: {
id: 'app.meeting.endedByUserMessage',
description: 'message informing who ended the meeting',
},
buttonOkay: {
id: 'app.meeting.endNotification.ok.label',
description: 'label okay for button',
@ -116,6 +121,17 @@ class MeetingEnded extends PureComponent {
this.localUserRole = user.role;
}
const meeting = Meetings.findOne({ id: user.meetingID });
if (meeting) {
const endedBy = Users.findOne({
userId: meeting.meetingEndedBy,
}, { fields: { name: 1 } });
if (endedBy) {
this.meetingEndedBy = endedBy.name;
}
}
this.setSelectedStar = this.setSelectedStar.bind(this);
this.confirmRedirect = this.confirmRedirect.bind(this);
this.sendFeedback = this.sendFeedback.bind(this);
@ -209,6 +225,11 @@ class MeetingEnded extends PureComponent {
</h1>
{!allowRedirectToLogoutURL() ? null : (
<div>
{this.meetingEndedBy ? (
<div className={styles.text}>
{intl.formatMessage(intlMessage.messageEndedByUser, { 0: this.meetingEndedBy })}
</div>
) : null}
<div className={styles.text}>
{intl.formatMessage(intlMessage.messageEnded)}
</div>

View File

@ -10,6 +10,7 @@ import Button from '/imports/ui/components/button/component';
import Checkbox from '/imports/ui/components/checkbox/component';
import LiveResult from './live-result/component';
import { styles } from './styles.scss';
import DragAndDrop from './dragAndDrop/component';
const intlMessages = defineMessages({
pollPaneTitle: {
@ -32,6 +33,10 @@ const intlMessages = defineMessages({
id: 'app.poll.activePollInstruction',
description: 'instructions displayed when a poll is active',
},
dragDropPollInstruction: {
id: 'app.poll.dragDropPollInstruction',
description: 'instructions for upload poll options via drag and drop',
},
ariaInputCount: {
id: 'app.poll.ariaInputCount',
description: 'aria label for custom poll input field',
@ -153,6 +158,7 @@ const intlMessages = defineMessages({
const CHAT_ENABLED = Meteor.settings.public.chat.enabled;
const MAX_CUSTOM_FIELDS = Meteor.settings.public.poll.max_custom;
const MAX_INPUT_CHARS = 45;
const FILE_DRAG_AND_DROP_ENABLED = Meteor.settings.public.poll.allowDragAndDropFile;
const validateInput = (i) => {
let _input = i;
@ -213,6 +219,21 @@ class Poll extends Component {
});
}
handleInputChange(index, event) {
this.handleInputTextChange(index, event.target.value);
}
handleInputTextChange(index, text) {
const { optList } = this.state;
// This regex will replace any instance of 2 or more consecutive white spaces
// with a single white space character.
const option = text.replace(/\s{2,}/g, ' ').trim();
if (index < optList.length) optList[index].val = option === '' ? '' : option;
this.setState({ optList });
}
handleInputChange(e, index) {
const { optList, type, error } = this.state;
const list = [...optList];
@ -235,12 +256,32 @@ class Poll extends Component {
const clearError = validatedQuestion.length > 0 && type === 'RP';
this.setState({ question: validateInput(e.target.value), error: clearError ? null : error });
}
pushToCustomPollValues(text) {
const lines = text.split('\n');
for (let i = 0; i < MAX_CUSTOM_FIELDS; i += 1) {
let line = '';
if (i < lines.length) {
line = lines[i];
line = line.length > MAX_INPUT_CHARS ? line.substring(0, MAX_INPUT_CHARS) : line;
}
this.handleInputTextChange(i, line);
}
}
handlePollValuesText(text) {
if (text && text.length > 0) {
this.pushToCustomPollValues(text);
}
}
handleRemoveOption(index) {
const { optList } = this.state;
const list = [...optList];
list.splice(index, 1);
this.setState({ optList: list });
}
handleAddOption() {
const { optList } = this.state;
this.setState({ optList: [...optList, { val: '' }] });
@ -524,6 +565,9 @@ class Poll extends Component {
});
}}
/>
{
FILE_DRAG_AND_DROP_ENABLED && this.renderDragDrop()
}
</div>
)
}
@ -565,6 +609,25 @@ class Poll extends Component {
return this.renderPollOptions();
}
renderDragDrop() {
const { intl } = this.props;
return (
<div>
<div className={styles.instructions}>
{intl.formatMessage(intlMessages.dragDropPollInstruction)}
</div>
<DragAndDrop
{...{ intl, MAX_INPUT_CHARS }}
handlePollValuesText={e => this.handlePollValuesText(e)}
>
<div className={styles.dragAndDropPollContainer} />
</DragAndDrop>
</div>
);
}
render() {
const {
intl,

View File

@ -0,0 +1,143 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withModalMounter } from '/imports/ui/components/modal/service';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles.scss';
import Button from '/imports/ui/components/button/component';
// src: https://medium.com/@650egor/simple-drag-and-drop-file-upload-in-react-2cb409d88929
const intlMessages = defineMessages({
customPollTextArea: {
id: 'app.poll.customPollTextArea',
description: 'label for button to submit custom poll values',
},
});
class DragAndDrop extends Component {
static handleDrag(e) {
e.preventDefault();
e.stopPropagation();
}
constructor(props) {
super(props);
this.state = {
drag: false,
pollValueText: '',
};
this.dropRef = React.createRef();
}
componentDidMount() {
this.dragCounter = 0;
const div = this.dropRef.current;
div.addEventListener('dragenter', e => this.handleDragIn(e));
div.addEventListener('dragleave', e => this.handleDragOut(e));
div.addEventListener('dragover', e => DragAndDrop.handleDrag(e));
div.addEventListener('drop', e => this.handleDrop(e));
}
componentWillUnmount() {
const div = this.dropRef.current;
div.removeEventListener('dragenter', e => this.handleDragIn(e));
div.removeEventListener('dragleave', e => this.handleDragOut(e));
div.removeEventListener('dragover', e => DragAndDrop.handleDrag(e));
div.removeEventListener('drop', e => this.handleDrop(e));
}
setPollValues() {
const { pollValueText } = this.state;
const { handlePollValuesText } = this.props;
if (pollValueText) {
handlePollValuesText(pollValueText);
}
}
setPollValuesFromFile(file) {
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target.result;
this.setPollValueText(text);
this.setPollValues();
};
reader.readAsText(file);
}
setPollValueText(pollText) {
const { MAX_INPUT_CHARS } = this.props;
const arr = pollText.split('\n');
const text = arr.map(line => (line.length > MAX_INPUT_CHARS ? line.substring(0, MAX_INPUT_CHARS) : line)).join('\n');
this.setState({ pollValueText: text });
}
handleTextInput(e) {
this.setPollValueText(e.target.value);
}
handleDragIn(e) {
DragAndDrop.handleDrag(e);
this.dragCounter += 1;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
this.setState({ drag: true });
}
}
handleDragOut(e) {
DragAndDrop.handleDrag(e);
this.dragCounter -= 1;
if (this.dragCounter > 0) return;
this.setState({ drag: false });
}
handleDrop(e) {
DragAndDrop.handleDrag(e);
this.setState({ drag: false });
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
this.setPollValuesFromFile(e.dataTransfer.files[0]);
this.dragCounter = 0;
}
}
render() {
const { intl, children } = this.props;
const { pollValueText, drag } = this.state;
return (
<div
className={styles.dndContainer}
ref={this.dropRef}
>
<textarea
value={pollValueText}
className={drag ? styles.dndActive : styles.dndInActive}
onChange={e => this.handleTextInput(e)}
/>
<Button
onClick={() => this.setPollValues()}
label={intl.formatMessage(intlMessages.customPollTextArea)}
color="primary"
disabled={pollValueText < 1}
className={styles.btn}
/>
{children}
</div>
);
}
} export default withModalMounter(injectIntl(DragAndDrop));
DragAndDrop.propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
MAX_INPUT_CHARS: PropTypes.number.isRequired,
handlePollValuesText: PropTypes.func.isRequired,
children: PropTypes.element.isRequired,
};

View File

@ -0,0 +1,26 @@
@import "/imports/ui/stylesheets/variables/_all";
.dndContainer {
height: 200px;
}
.customPollValuesTextfield {
width: 100%;
height: 100%;
resize: none;
font-size: var(--font-size-small);
}
.dndActive {
@extend .customPollValuesTextfield;
background: grey;
}
.dndInActive {
@extend .customPollValuesTextfield;
background: white;
}
.btn {
width: 100%;
}

View File

@ -363,3 +363,8 @@
color: var(--color-white) !important;
}
}
.dragAndDropPollContainer {
width: 200px !important;
height: 200px !important;
}

View File

@ -56,6 +56,7 @@ class Polling extends Component {
this.handleCheckboxChange = this.handleCheckboxChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.renderCheckboxAnswers = this.renderCheckboxAnswers.bind(this);
this.handleMessageKeyDown = this.handleMessageKeyDown.bind(this);
}
componentDidMount() {
@ -91,6 +92,22 @@ class Polling extends Component {
this.setState({ checkedAnswers });
}
handleMessageKeyDown(e) {
const {
poll,
handleTypedVote,
} = this.props;
const {
typedAns,
} = this.state;
if (e.keyCode === 13) {
handleTypedVote(poll.pollId, typedAns);
}
}
renderButtonAnswers(pollAnswerStyles) {
const {
isMeteorConnected,
@ -167,6 +184,9 @@ class Polling extends Component {
onChange={(e) => {
this.handleUpdateResponseInput(e);
}}
onKeyDown={(e) => {
this.handleMessageKeyDown(e);
}}
type="text"
className={styles.typedResponseInput}
placeholder={intl.formatMessage(intlMessages.responsePlaceholder)}
@ -308,6 +328,7 @@ Polling.propTypes = {
formatMessage: PropTypes.func.isRequired,
}).isRequired,
handleVote: PropTypes.func.isRequired,
handleTypedVote: PropTypes.func.isRequired,
poll: PropTypes.shape({
pollId: PropTypes.string.isRequired,
answers: PropTypes.arrayOf(PropTypes.shape({

View File

@ -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,
};

View File

@ -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,

View File

@ -3,7 +3,6 @@ import KurentoBridge from '/imports/api/screenshare/client/bridge';
import BridgeService from '/imports/api/screenshare/client/bridge/service';
import Settings from '/imports/ui/services/settings';
import logger from '/imports/startup/client/logger';
import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc';
import { stopWatching } from '/imports/ui/components/external-video-player/service';
import Meetings from '/imports/api/meetings';
import Auth from '/imports/ui/services/auth';
@ -11,65 +10,119 @@ 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;
}
const screenshareHasEnded = () => {
if (isSharingScreen()) {
setSharingScreen(false);
}
KurentoBridge.stop();
};
// 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 getMediaElement = () => {
return document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME);
}
const viewScreenshare = () => {
const amIPresenter = UserListService.isUserPresenter(Auth.userID);
if (!amIPresenter) {
KurentoBridge.kurentoViewScreen();
} else {
KurentoBridge.kurentoViewLocalPreview();
const attachLocalPreviewStream = (mediaElement) => {
const stream = KurentoBridge.gdmStream;
if (stream && mediaElement) {
// Always muted, presenter preview.
BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, true);
}
}
const screenshareHasStarted = () => {
// Presenter's screen preview is local, so skip
if (!UserListService.amIPresenter()) {
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 +131,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,
};

View File

@ -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%);
}

View File

@ -79,6 +79,7 @@ const propTypes = {
userJoinAudioAlerts: PropTypes.bool,
guestWaitingAudioAlerts: PropTypes.bool,
guestWaitingPushAlerts: PropTypes.bool,
paginationEnabled: PropTypes.bool,
fallbackLocale: PropTypes.string,
fontSize: PropTypes.string,
locale: PropTypes.string,

View File

@ -27,7 +27,7 @@ const updateSettings = (obj, msg) => {
}
};
const getAvailableLocales = () => fetch('./locales').then(locales => locales.json());
const getAvailableLocales = () => fetch('./locale-list').then(locales => locales.json());
export {
getUserRoles,

View File

@ -6,6 +6,7 @@ import LocalesDropdown from '/imports/ui/components/locales-dropdown/component';
import { defineMessages, injectIntl } from 'react-intl';
import BaseMenu from '../base/component';
import { styles } from '../styles';
import VideoService from '/imports/ui/components/video-provider/service';
const MIN_FONTSIZE = 0;
@ -58,6 +59,10 @@ const intlMessages = defineMessages({
id: 'app.submenu.application.noLocaleOptionLabel',
description: 'default change language option when no locales available',
},
paginationEnabledLabel: {
id: 'app.submenu.application.paginationEnabledLabel',
description: 'enable/disable video pagination',
},
});
class ApplicationMenu extends BaseMenu {
@ -199,6 +204,35 @@ class ApplicationMenu extends BaseMenu {
this.handleUpdateSettings('application', obj.settings);
}
renderPaginationToggle() {
// See VideoService's method for an explanation
if (!VideoService.shouldRenderPaginationToggle()) return;
const { intl } = this.props;
return (
<div className={styles.row}>
<div className={styles.col} aria-hidden="true">
<div className={styles.formElement}>
<label className={styles.label}>
{intl.formatMessage(intlMessages.paginationEnabledLabel)}
</label>
</div>
</div>
<div className={styles.col}>
<div className={cx(styles.formElement, styles.pullContentRight)}>
<Toggle
icons={false}
defaultChecked={this.state.settings.paginationEnabled}
onChange={() => this.handleToggle('paginationEnabled')}
ariaLabel={intl.formatMessage(intlMessages.paginationEnabledLabel)}
/>
</div>
</div>
</div>
);
}
render() {
const { allLocales, intl } = this.props;
const {
@ -268,6 +302,9 @@ class ApplicationMenu extends BaseMenu {
</div>
</div>
</div>
{this.renderPaginationToggle()}
<div className={styles.row}>
<div className={styles.col} aria-hidden="true">
<div className={styles.formElement}>

View File

@ -0,0 +1,81 @@
import React, { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import TextareaAutosize from 'react-autosize-textarea';
import PropTypes from 'prop-types';
import logger from '/imports/startup/client/logger';
import Button from '/imports/ui/components/button/component';
import { styles } from './styles.scss';
const propTypes = {
placeholder: PropTypes.string,
send: PropTypes.func.isRequired,
};
const defaultProps = {
placeholder: '',
send: () => logger.warn({ logCode: 'text_input_send_function' }, `Missing`),
};
const messages = defineMessages({
sendLabel: {
id: 'app.textInput.sendLabel',
description: 'Text input send button label',
},
});
class TextInput extends PureComponent {
constructor(props) {
super(props);
this.state = { message: '' };
}
handleOnChange(e) {
const message = e.target.value;
this.setState({ message });
}
handleOnClick() {
const { send } = this.props;
const { message } = this.state;
send(message);
this.setState({ message: '' });
}
render() {
const {
intl,
maxLength,
placeholder,
} = this.props;
const { message } = this.state;
return (
<div className={styles.wrapper}>
<TextareaAutosize
className={styles.textarea}
maxLength={maxLength}
onChange={(e) => this.handleOnChange(e)}
placeholder={placeholder}
value={message}
/>
<Button
circle
className={styles.button}
color="primary"
hideLabel
icon="send"
label={intl.formatMessage(messages.sendLabel)}
onClick={() => this.handleOnClick()}
/>
</div>
);
}
}
TextInput.propTypes = propTypes;
TextInput.defaultProps = defaultProps;
export default injectIntl(TextInput);

View File

@ -0,0 +1,53 @@
@import "/imports/ui/stylesheets/mixins/focus";
@import "/imports/ui/stylesheets/mixins/_indicators";
@import "/imports/ui/stylesheets/variables/_all";
.wrapper {
display: flex;
flex-direction: row;
}
.textarea {
@include inputFocus(var(--color-blue-light));
flex: 1;
background: #fff;
background-clip: padding-box;
margin: 0;
color: var(--color-text);
-webkit-appearance: none;
padding: calc(var(--sm-padding-y) * 2.5) calc(var(--sm-padding-x) * 1.25);
resize: none;
transition: none;
border-radius: var(--border-radius);
font-size: var(--font-size-base);
min-height: 2.5rem;
max-height: 10rem;
border: 1px solid var(--color-gray-lighter);
box-shadow: 0 0 0 1px var(--color-gray-lighter);
&:hover {
@include highContrastOutline();
}
&:active,
&:focus {
@include highContrastOutline();
outline-style: solid;
}
}
.button {
margin:0 0 0 var(--sm-padding-x);
align-self: center;
font-size: 0.9rem;
[dir="rtl"] & {
margin: 0 var(--sm-padding-x) 0 0;
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
}

View File

@ -578,6 +578,10 @@ const isUserPresenter = (userId) => {
return user ? user.presenter : false;
};
const amIPresenter = () => {
return isUserPresenter(Auth.userID);
};
export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => {
const mimeType = 'text/plain';
const userNamesObj = getUsers()
@ -646,5 +650,6 @@ export default {
requestUserInformation,
focusFirstDropDownItem,
isUserPresenter,
amIPresenter,
getUsersProp,
};

View File

@ -11,6 +11,7 @@ import DropdownList from '/imports/ui/components/dropdown/list/component';
import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
import LockViewersContainer from '/imports/ui/components/lock-viewers/container';
import ConnectionStatusContainer from '/imports/ui/components/connection-status/modal/container';
import GuestPolicyContainer from '/imports/ui/components/waiting-users/guest-policy/container';
import BreakoutRoom from '/imports/ui/components/actions-bar/create-breakout-room/container';
import CaptionsService from '/imports/ui/components/captions/service';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
@ -29,6 +30,7 @@ const propTypes = {
toggleStatus: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,
users: PropTypes.arrayOf(Object).isRequired,
guestPolicy: PropTypes.string.isRequired,
meetingIsBreakout: PropTypes.bool.isRequired,
hasBreakoutRoom: PropTypes.bool.isRequired,
isBreakoutEnabled: PropTypes.bool.isRequired,
@ -80,6 +82,14 @@ const intlMessages = defineMessages({
id: 'app.userList.userOptions.connectionStatusDesc',
description: 'Connection status description',
},
guestPolicyLabel: {
id: 'app.userList.userOptions.guestPolicyLabel',
description: 'Guest policy label',
},
guestPolicyDesc: {
id: 'app.userList.userOptions.guestPolicyDesc',
description: 'Guest policy description',
},
muteAllExceptPresenterLabel: {
id: 'app.userList.userOptions.muteAllExceptPresenterLabel',
description: 'Mute all except presenter label',
@ -139,6 +149,7 @@ class UserOptions extends PureComponent {
this.muteAllId = _.uniqueId('list-item-');
this.lockId = _.uniqueId('list-item-');
this.connectionStatusId = _.uniqueId('list-item-');
this.guestPolicyId = _.uniqueId('list-item-');
this.createBreakoutId = _.uniqueId('list-item-');
this.saveUsersNameId = _.uniqueId('list-item-');
this.captionsId = _.uniqueId('list-item-');
@ -296,6 +307,15 @@ class UserOptions extends PureComponent {
onClick={() => mountModal(<ConnectionStatusContainer />)}
/>) : null
),
(!meetingIsBreakout && isMeteorConnected ? (
<DropdownListItem
key={this.guestPolicyId}
icon="user"
label={intl.formatMessage(intlMessages.guestPolicyLabel)}
description={intl.formatMessage(intlMessages.guestPolicyDesc)}
onClick={() => mountModal(<GuestPolicyContainer />)}
/>) : null
),
(isMeteorConnected ? <DropdownListSeparator key={_.uniqueId('list-separator-')} /> : null),
(canCreateBreakout && isMeteorConnected ? (
<DropdownListItem

View File

@ -4,6 +4,7 @@ import Auth from '/imports/ui/services/auth';
import Meetings from '/imports/api/meetings';
import ActionsBarService from '/imports/ui/components/actions-bar/service';
import UserListService from '/imports/ui/components/user-list/service';
import WaitingUsersService from '/imports/ui/components/waiting-users/service';
import logger from '/imports/startup/client/logger';
import { defineMessages, injectIntl } from 'react-intl';
import { notify } from '/imports/ui/services/notification';
@ -84,6 +85,7 @@ const UserOptionsContainer = withTracker((props) => {
isBreakoutEnabled: ActionsBarService.isBreakoutEnabled(),
isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(),
users: ActionsBarService.users(),
guestPolicy: WaitingUsersService.getGuestPolicy(),
isMeteorConnected: Meteor.status().connected,
meetingName: getMeetingName(),
};

View File

@ -9,8 +9,8 @@ import {
fetchWebRTCMappedStunTurnServers,
getMappedFallbackStun,
} from '/imports/utils/fetchStunTurnServers';
import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc';
import logger from '/imports/startup/client/logger';
import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
// Default values and default empty object to be backwards compat with 2.2.
// FIXME Remove hardcoded defaults 2.3.
@ -107,7 +107,6 @@ class VideoProvider extends Component {
{ connectionTimeout: WS_CONN_TIMEOUT },
);
this.wsQueue = [];
this.restartTimeout = {};
this.restartTimer = {};
this.webRtcPeers = {};
@ -115,10 +114,10 @@ class VideoProvider extends Component {
this.videoTags = {};
this.createVideoTag = this.createVideoTag.bind(this);
this.destroyVideoTag = this.destroyVideoTag.bind(this);
this.onWsOpen = this.onWsOpen.bind(this);
this.onWsClose = this.onWsClose.bind(this);
this.onWsMessage = this.onWsMessage.bind(this);
this.updateStreams = this.updateStreams.bind(this);
this.debouncedConnectStreams = _.debounce(
this.connectStreams,
@ -164,7 +163,7 @@ class VideoProvider extends Component {
VideoService.exitVideo();
Object.keys(this.webRtcPeers).forEach((cameraId) => {
this.stopWebRTCPeer(cameraId);
this.stopWebRTCPeer(cameraId, false);
});
// Close websocket connection to prevent multiple reconnects from happening
@ -204,7 +203,7 @@ class VideoProvider extends Component {
}
onWsClose() {
logger.debug({
logger.info({
logCode: 'video_provider_onwsclose',
}, 'Multiple video provider websocket connection closed.');
@ -216,7 +215,7 @@ class VideoProvider extends Component {
}
onWsOpen() {
logger.debug({
logger.info({
logCode: 'video_provider_onwsopen',
}, 'Multiple video provider websocket connection opened.');
@ -266,7 +265,7 @@ class VideoProvider extends Component {
}
disconnectStreams(streamsToDisconnect) {
streamsToDisconnect.forEach(cameraId => this.stopWebRTCPeer(cameraId));
streamsToDisconnect.forEach(cameraId => this.stopWebRTCPeer(cameraId, false));
}
updateStreams(streams, shouldDebounce = false) {
@ -300,10 +299,10 @@ class VideoProvider extends Component {
logger.error({
logCode: 'video_provider_ws_send_error',
extraInfo: {
sfuRequest: message,
error,
errorMessage: error.message || 'Unknown',
errorCode: error.code,
},
}, `WebSocket failed when sending request to SFU due to ${error.message}`);
}, 'Camera request failed to be sent to SFU');
}
});
} else if (message.id !== 'stop') {
@ -328,13 +327,10 @@ class VideoProvider extends Component {
const { cameraId, role } = message;
const peer = this.webRtcPeers[cameraId];
logger.info({
logger.debug({
logCode: 'video_provider_start_response_success',
extraInfo: {
cameraId,
sfuResponse: message,
},
}, `Camera start request was accepted by SFU, processing response for ${cameraId}`);
extraInfo: { cameraId, role },
}, `Camera start request accepted by SFU. Role: ${role}`);
if (peer) {
peer.processAnswer(message.sdpAnswer, (error) => {
@ -343,9 +339,11 @@ class VideoProvider extends Component {
logCode: 'video_provider_peerconnection_processanswer_error',
extraInfo: {
cameraId,
error,
role,
errorMessage: error.message,
errorCode: error.code,
},
}, `Processing SDP answer from SFU for ${cameraId} failed due to ${error.message}`);
}, 'Camera answer processing failed');
return;
}
@ -357,7 +355,8 @@ class VideoProvider extends Component {
} else {
logger.warn({
logCode: 'video_provider_startresponse_no_peer',
}, `SFU start response for ${cameraId} arrived after the peer was discarded, ignore it.`);
extraInfo: { cameraId, role },
}, 'No peer on SFU camera start response handler');
}
}
@ -365,13 +364,6 @@ class VideoProvider extends Component {
const { cameraId, candidate } = message;
const peer = this.webRtcPeers[cameraId];
logger.debug({
logCode: 'video_provider_ice_candidate_received',
extraInfo: {
candidate,
},
}, `video-provider received candidate for ${cameraId}: ${JSON.stringify(candidate)}`);
if (peer) {
if (peer.didSDPAnswered) {
VideoService.addCandidateToPeer(peer, candidate, cameraId);
@ -389,7 +381,19 @@ class VideoProvider extends Component {
} else {
logger.warn({
logCode: 'video_provider_addicecandidate_no_peer',
}, `SFU ICE candidate for ${cameraId} arrived after the peer was discarded, ignore it.`);
extraInfo: { cameraId },
}, 'Trailing camera ICE candidate, discarded');
}
}
clearRestartTimers (cameraId) {
if (this.restartTimeout[cameraId]) {
clearTimeout(this.restartTimeout[cameraId]);
delete this.restartTimeout[cameraId];
}
if (this.restartTimer[cameraId]) {
delete this.restartTimer[cameraId];
}
}
@ -412,7 +416,9 @@ class VideoProvider extends Component {
logger.info({
logCode: 'video_provider_stopping_webcam_sfu',
}, `Sending stop request to SFU. Camera: ${cameraId}, role ${role} and flag restarting ${restarting}`);
extraInfo: { role, cameraId, restarting },
}, `Camera feed stop requested. Role ${role}, restarting ${restarting}`);
this.sendMessage({
id: 'stop',
type: 'video',
@ -423,14 +429,7 @@ class VideoProvider extends Component {
// Clear the shared camera media flow timeout and current reconnect period
// when destroying it if the peer won't restart
if (!restarting) {
if (this.restartTimeout[cameraId]) {
clearTimeout(this.restartTimeout[cameraId]);
delete this.restartTimeout[cameraId];
}
if (this.restartTimer[cameraId]) {
delete this.restartTimer[cameraId];
}
this.clearRestartTimers(cameraId);
}
this.destroyWebRTCPeer(cameraId);
@ -438,10 +437,10 @@ class VideoProvider extends Component {
destroyWebRTCPeer(cameraId) {
const peer = this.webRtcPeers[cameraId];
const isLocal = VideoService.isLocalStream(cameraId);
const role = VideoService.getRole(isLocal);
if (peer) {
logger.info({
logCode: 'video_provider_destroywebrtcpeer',
}, `Disposing WebRTC peer ${cameraId}`);
if (typeof peer.dispose === 'function') {
peer.dispose();
}
@ -450,12 +449,14 @@ class VideoProvider extends Component {
} else {
logger.warn({
logCode: 'video_provider_destroywebrtcpeer_no_peer',
}, `Peer ${cameraId} was already disposed (glare), ignore it.`);
extraInfo: { cameraId, role },
}, 'Trailing camera destroy request.');
}
}
async createWebRTCPeer(cameraId, isLocal) {
let iceServers = [];
const role = VideoService.getRole(isLocal);
// Check if the peer is already being processed
if (this.webRtcPeers[cameraId]) {
@ -464,28 +465,14 @@ class VideoProvider extends Component {
this.webRtcPeers[cameraId] = {};
// WebRTC restrictions may need a capture device permission to release
// useful ICE candidates on recvonly/no-gUM peers
if (!isLocal) {
try {
await tryGenerateIceCandidates();
} catch (error) {
logger.error({
logCode: 'video_provider_no_valid_candidate_gum_failure',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Forced gUM to release additional ICE candidates failed due to ${error.name}.`);
}
}
try {
iceServers = await fetchWebRTCMappedStunTurnServers(this.info.sessionToken);
} catch (error) {
logger.error({
logCode: 'video_provider_fetchstunturninfo_error',
extraInfo: {
cameraId,
role,
errorCode: error.code,
errorMessage: error.message,
},
@ -542,7 +529,7 @@ class VideoProvider extends Component {
id: 'start',
type: 'video',
cameraId,
role: VideoService.getRole(isLocal),
role,
sdpOffer: offerSdp,
meetingId: this.info.meetingId,
voiceBridge: this.info.voiceBridge,
@ -555,12 +542,14 @@ class VideoProvider extends Component {
logger.info({
logCode: 'video_provider_sfu_request_start_camera',
extraInfo: {
sfuRequest: message,
cameraId,
cameraProfile: profileId,
role,
},
}, `Camera offer generated. Sending start request to SFU for ${cameraId}`);
}, `Camera offer generated. Role: ${role}`);
this.sendMessage(message);
this.setReconnectionTimeout(cameraId, isLocal, false);
return false;
});
@ -570,8 +559,9 @@ class VideoProvider extends Component {
const peer = this.webRtcPeers[cameraId];
if (peer && peer.peerConnection) {
const conn = peer.peerConnection;
conn.oniceconnectionstatechange = this
._getOnIceConnectionStateChangeCallback(cameraId, isLocal);
conn.onconnectionstatechange = () => {
this._handleIceConnectionStateChange(cameraId, isLocal);
};
VideoService.monitor(conn);
}
}
@ -581,16 +571,12 @@ class VideoProvider extends Component {
const { intl } = this.props;
return () => {
// Peer that timed out is a sharer/publisher
if (isLocal) {
logger.error({
logCode: 'video_provider_camera_share_timeout',
extraInfo: { cameraId },
}, `Camera SHARER has not succeeded in ${CAMERA_SHARE_FAILED_WAIT_TIME} for ${cameraId}`);
VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout));
this.stopWebRTCPeer(cameraId);
} else {
const role = VideoService.getRole(isLocal);
if (!isLocal) {
// Peer that timed out is a subscriber/viewer
// Subscribers try to reconnect according to their timers if media could
// not reach the server. That's why we pass the restarting flag as true
// to the stop procedure as to not destroy the timers
// Create new reconnect interval time
const oldReconnectTimer = this.restartTimer[cameraId];
const newReconnectTimer = Math.min(
@ -604,80 +590,87 @@ class VideoProvider extends Component {
delete this.restartTimeout[cameraId];
}
// Peer that timed out is a subscriber/viewer
// Subscribers try to reconnect according to their timers if media could
// not reach the server. That's why we pass the restarting flag as true
// to the stop procedure as to not destroy the timers
logger.error({
logCode: 'video_provider_camera_view_timeout',
extraInfo: {
cameraId,
role,
oldReconnectTimer,
newReconnectTimer,
},
}, `Camera VIEWER has not succeeded in ${oldReconnectTimer} for ${cameraId}. Reconnecting.`);
}, 'Camera VIEWER failed. Reconnecting.');
this.stopWebRTCPeer(cameraId, true);
this.createWebRTCPeer(cameraId, isLocal);
this.reconnect(cameraId, isLocal);
} else {
// Peer that timed out is a sharer/publisher, clean it up, stop.
logger.error({
logCode: 'video_provider_camera_share_timeout',
extraInfo: {
cameraId,
role,
},
}, 'Camera SHARER failed.');
VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout));
this.stopWebRTCPeer(cameraId, false);
}
};
}
_onWebRTCError(error, cameraId, isLocal) {
const { intl } = this.props;
// 2001 means MEDIA_SERVER_OFFLINE. It's a server-wide error.
// We only display it to a sharer/publisher instance to avoid popping up
// redundant toasts.
// If the client only has viewer instances, the WS will close unexpectedly
// and an error will be shown there for them.
if (error === 2001 && !isLocal) {
return;
}
const errorMessage = intlClientErrors[error.name]
|| intlSFUErrors[error] || intlClientErrors.permissionError;
// Only display WebRTC negotiation error toasts to sharers. The viewer streams
// will try to autoreconnect silently, but the error will log nonetheless
if (isLocal) {
VideoService.notify(intl.formatMessage(errorMessage));
} else {
// If it's a viewer, set the reconnection timeout. There's a good chance
// no local candidate was generated and it wasn't set.
this.setReconnectionTimeout(cameraId, isLocal);
}
// shareWebcam as the second argument means it will only try to reconnect if
// it's a viewer instance (see stopWebRTCPeer restarting argument)
this.stopWebRTCPeer(cameraId, !isLocal);
const errorMessage = intlClientErrors[error.name] || intlSFUErrors[error];
logger.error({
logCode: 'video_provider_webrtc_peer_error',
extraInfo: {
cameraId,
normalizedError: errorMessage,
error,
role: VideoService.getRole(isLocal),
errorName: error.name,
errorMessage: error.message,
},
}, `Camera peer creation failed for ${cameraId} due to ${error.message}`);
}, 'Camera peer failed');
// Only display WebRTC negotiation error toasts to sharers. The viewer streams
// will try to autoreconnect silently, but the error will log nonetheless
if (isLocal) {
this.stopWebRTCPeer(cameraId, false);
if (errorMessage) VideoService.notify(intl.formatMessage(errorMessage));
} else {
// If it's a viewer, set the reconnection timeout. There's a good chance
// no local candidate was generated and it wasn't set.
const peer = this.webRtcPeers[cameraId];
const isEstablishedConnection = peer && peer.started;
this.setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection);
// second argument means it will only try to reconnect if
// it's a viewer instance (see stopWebRTCPeer restarting argument)
this.stopWebRTCPeer(cameraId, true);
}
}
setReconnectionTimeout(cameraId, isLocal) {
const peer = this.webRtcPeers[cameraId];
const peerHasStarted = peer && peer.started === true;
const shouldSetReconnectionTimeout = !this.restartTimeout[cameraId] && !peerHasStarted;
reconnect(cameraId, isLocal) {
this.stopWebRTCPeer(cameraId, true);
this.createWebRTCPeer(cameraId, isLocal);
}
setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection) {
const peer = this.webRtcPeers[cameraId];
const shouldSetReconnectionTimeout = !this.restartTimeout[cameraId] && !isEstablishedConnection;
// This is an ongoing reconnection which succeeded in the first place but
// then failed mid call. Try to reconnect it right away. Clear the restart
// timers since we don't need them in this case.
if (isEstablishedConnection) {
this.clearRestartTimers(cameraId);
return this.reconnect(cameraId, isLocal);
}
// This is a reconnection timer for a peer that hasn't succeeded in the first
// place. Set reconnection timeouts with random intervals between them to try
// and reconnect without flooding the server
if (shouldSetReconnectionTimeout) {
const newReconnectTimer = this.restartTimer[cameraId] || CAMERA_SHARE_FAILED_WAIT_TIME;
this.restartTimer[cameraId] = newReconnectTimer;
logger.info({
logCode: 'video_provider_setup_reconnect',
extraInfo: {
cameraId,
reconnectTimer: newReconnectTimer,
},
}, `Camera has a new reconnect timer of ${newReconnectTimer} ms for ${cameraId}`);
this.restartTimeout[cameraId] = setTimeout(
this._getWebRTCStartTimeout(cameraId, isLocal),
this.restartTimer[cameraId],
@ -689,16 +682,8 @@ class VideoProvider extends Component {
return (candidate) => {
const peer = this.webRtcPeers[cameraId];
const role = VideoService.getRole(isLocal);
// Setup a timeout only when the first candidate is generated and if the peer wasn't
// marked as started already (which is done on handlePlayStart after
// it was verified that media could circle through the server)
this.setReconnectionTimeout(cameraId, isLocal);
if (peer && !peer.didSDPAnswered) {
logger.debug({
logCode: 'video_provider_client_candidate',
extraInfo: { candidate },
}, `video-provider client-side candidate queued for ${cameraId}`);
this.outboundIceQueues[cameraId].push(candidate);
return;
}
@ -708,10 +693,6 @@ class VideoProvider extends Component {
}
sendIceCandidateToSFU(peer, role, candidate, cameraId) {
logger.debug({
logCode: 'video_provider_client_candidate',
extraInfo: { candidate },
}, `video-provider client-side candidate generated for ${cameraId}: ${JSON.stringify(candidate)}`);
const message = {
type: 'video',
role,
@ -722,111 +703,111 @@ class VideoProvider extends Component {
this.sendMessage(message);
}
_getOnIceConnectionStateChangeCallback(cameraId, isLocal) {
_handleIceConnectionStateChange (cameraId, isLocal) {
const { intl } = this.props;
const peer = this.webRtcPeers[cameraId];
const role = VideoService.getRole(isLocal);
if (peer && peer.peerConnection) {
const conn = peer.peerConnection;
const { iceConnectionState } = conn;
const pc = peer.peerConnection;
const connectionState = pc.connectionState;
notifyStreamStateChange(cameraId, connectionState);
return () => {
if (iceConnectionState === 'failed' || iceConnectionState === 'closed') {
// prevent the same error from being detected multiple times
conn.oniceconnectionstatechange = null;
logger.error({
logCode: 'video_provider_ice_connection_failed_state',
extraInfo: {
cameraId,
iceConnectionState,
},
}, `ICE connection state transitioned to ${iceConnectionState} for ${cameraId}`);
if (connectionState === 'failed' || connectionState === 'closed') {
const error = new Error('iceConnectionStateError');
// prevent the same error from being detected multiple times
pc.onconnectionstatechange = null;
this.stopWebRTCPeer(cameraId);
VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError));
}
};
}
return () => {
logger.error({
logCode: 'video_provider_ice_connection_failed_state',
extraInfo: {
logger.error({
logCode: 'video_provider_ice_connection_failed_state',
extraInfo: {
cameraId,
connectionState,
role,
},
}, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`);
if (isLocal) VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError));
this._onWebRTCError(
error,
cameraId,
iceConnectionState: undefined,
},
}, `Missing peer at ICE connection state transition for ${cameraId}`);
// isLocal as the second argument means it will only try to reconnect if
// it's a viewer instance (see stopWebRTCPeer restarting argument)
this.stopWebRTCPeer(cameraId, !isLocal);
VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError));
};
isLocal
);
}
} else {
logger.error({
logCode: 'video_provider_ice_connection_nopeer',
extraInfo: { cameraId, role },
}, `No peer at ICE connection state handler. Camera: ${cameraId}. Role: ${role}`);
}
}
attachVideoStream(cameraId) {
const video = this.videoTags[cameraId];
if (video == null) {
logger.warn({
logCode: 'video_provider_delay_attach_video_stream',
extraInfo: { cameraId },
}, `Will attach stream later because camera has not started yet for ${cameraId}`);
}, 'Delaying video stream attachment');
return;
}
if (video.srcObject) {
delete this.videoTags[cameraId];
return; // Skip if the stream is already attached
}
const isLocal = VideoService.isLocalStream(cameraId);
const peer = this.webRtcPeers[cameraId];
if (peer && peer.attached && video.srcObject) {
return; // Skip if the stream is already attached
}
const attachVideoStreamHelper = () => {
const stream = isLocal ? peer.getLocalStream() : peer.getRemoteStream();
video.pause();
video.srcObject = stream;
video.load();
peer.attached = true;
delete this.videoTags[cameraId];
};
// If peer has started playing attach to tag, otherwise wait a while
if (peer) {
if (peer.started) {
attachVideoStreamHelper();
}
// So we can start it later when we get a playStart
// or if we need to do a restart timeout
peer.videoTag = video;
}
// Conditions to safely attach a stream to a video element in all browsers:
// 1 - Peer exists
// 2 - It hasn't been attached yet
// 3a - If the stream is a local one (webcam sharer), we can just attach it
// (no need to wait for server confirmation)
// 3b - If the stream is a remote one, the safest (*ahem* Safari) moment to
// do so is waiting for the server to confirm that media has flown out of it
// towards the remote end.
const isAbleToAttach = peer && !peer.attached && (peer.started || isLocal);
if (isAbleToAttach) attachVideoStreamHelper();
}
createVideoTag(cameraId, video) {
const peer = this.webRtcPeers[cameraId];
this.videoTags[cameraId] = video;
if (peer) {
if (peer && !peer.attached) {
this.attachVideoStream(cameraId);
}
}
destroyVideoTag(cameraId) {
delete this.videoTags[cameraId]
}
handlePlayStop(message) {
const { cameraId } = message;
const { cameraId, role } = message;
logger.info({
logCode: 'video_provider_handle_play_stop',
extraInfo: {
cameraId,
sfuRequest: message,
role,
},
}, `Received request from SFU to stop camera ${cameraId}`);
}, `Received request from SFU to stop camera. Role: ${role}`);
this.stopWebRTCPeer(cameraId, false);
}
handlePlayStart(message) {
const { cameraId } = message;
const { cameraId, role } = message;
const peer = this.webRtcPeers[cameraId];
if (peer) {
@ -834,16 +815,14 @@ class VideoProvider extends Component {
logCode: 'video_provider_handle_play_start_flowing',
extraInfo: {
cameraId,
sfuResponse: message,
role,
},
}, `SFU says that media is flowing for camera ${cameraId}`);
}, `Camera media is flowing (server). Role: ${role}`);
peer.started = true;
// Clear camera shared timeout when camera succesfully starts
clearTimeout(this.restartTimeout[cameraId]);
delete this.restartTimeout[cameraId];
delete this.restartTimer[cameraId];
this.clearRestartTimers(cameraId);
if (!peer.attached) {
this.attachVideoStream(cameraId);
@ -851,8 +830,10 @@ class VideoProvider extends Component {
VideoService.playStart(cameraId);
} else {
logger.warn({ logCode: 'video_provider_playstart_no_peer' },
`SFU playStart response for ${cameraId} arrived after the peer was discarded, ignore it.`);
logger.warn({
logCode: 'video_provider_playstart_no_peer',
extraInfo: { cameraId, role },
}, 'Trailing camera playStart response.');
}
}
@ -860,15 +841,19 @@ class VideoProvider extends Component {
const { intl } = this.props;
const { code, reason, streamId } = message;
const cameraId = streamId;
const isLocal = VideoService.isLocalStream(cameraId);
const role = VideoService.getRole(isLocal);
logger.error({
logCode: 'video_provider_handle_sfu_error',
extraInfo: {
error: message,
errorCode: code,
errorReason: reason,
cameraId,
role,
},
}, `SFU returned error for camera ${cameraId}. Code: ${code}, reason: ${reason}`);
}, `SFU returned an error. Code: ${code}, reason: ${reason}`);
const isLocal = VideoService.isLocalStream(cameraId);
if (isLocal) {
// The publisher instance received an error from the server. There's no reconnect,
// stop it.
@ -885,7 +870,8 @@ class VideoProvider extends Component {
return (
<VideoListContainer
streams={streams}
onMount={this.createVideoTag}
onVideoItemMount={this.createVideoTag}
onVideoItemUnmount={this.destroyVideoTag}
swapLayout={swapLayout}
currentVideoPageIndex={currentVideoPageIndex}
/>

View File

@ -26,13 +26,14 @@ const ENABLE_NETWORK_MONITORING = Meteor.settings.public.networkMonitoring.enabl
const MIRROR_WEBCAM = Meteor.settings.public.app.mirrorOwnWebcam;
const CAMERA_QUALITY_THRESHOLDS = Meteor.settings.public.kurento.cameraQualityThresholds.thresholds || [];
const {
enabled: PAGINATION_ENABLED,
paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED,
pageChangeDebounceTime: PAGE_CHANGE_DEBOUNCE_TIME,
desktopPageSizes: DESKTOP_PAGE_SIZES,
mobilePageSizes: MOBILE_PAGE_SIZES,
} = Meteor.settings.public.kurento.pagination;
const TOKEN = '_';
const ENABLE_PAGINATION_SESSION_VAR = 'enablePagination';
class VideoService {
// Paginated streams: sort with following priority: local -> presenter -> alphabetic
@ -214,8 +215,13 @@ class VideoService {
return Auth.authenticateURL(SFU_URL);
}
shouldRenderPaginationToggle() {
// Only enable toggle if configured to do so and if we have a page size properly setup
return PAGINATION_TOGGLE_ENABLED && (this.getMyPageSize() > 0);
}
isPaginationEnabled () {
return PAGINATION_ENABLED && (this.getMyPageSize() > 0);
return Settings.application.paginationEnabled && (this.getMyPageSize() > 0);
}
setNumberOfPages (numberOfPublishers, numberOfSubscribers, pageSize) {
@ -338,7 +344,7 @@ class VideoService {
// Pagination is either explictly disabled or pagination is set to 0 (which
// is equivalent to disabling it), so return the mapped streams as they are
// which produces the original non paginated behaviour
if (!PAGINATION_ENABLED || pageSize === 0) {
if (!this.isPaginationEnabled() || pageSize === 0) {
return {
streams: mappedStreams.sort(VideoService.sortMeshStreams),
totalNumberOfStreams: mappedStreams.length
@ -776,4 +782,5 @@ export default {
getPreviousVideoPage: () => videoService.getPreviousVideoPage(),
getNextVideoPage: () => videoService.getNextVideoPage(),
getPageChangeDebounceTime: () => { return PAGE_CHANGE_DEBOUNCE_TIME },
shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(),
};

View File

@ -14,7 +14,8 @@ import Button from '/imports/ui/components/button/component';
const propTypes = {
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
onMount: PropTypes.func.isRequired,
onVideoItemMount: PropTypes.func.isRequired,
onVideoItemUnmount: PropTypes.func.isRequired,
webcamDraggableDispatch: PropTypes.func.isRequired,
intl: PropTypes.objectOf(Object).isRequired,
swapLayout: PropTypes.bool.isRequired,
@ -298,7 +299,8 @@ class VideoList extends Component {
const {
intl,
streams,
onMount,
onVideoItemMount,
onVideoItemUnmount,
swapLayout,
} = this.props;
const { focusedId } = this.state;
@ -340,10 +342,11 @@ class VideoList extends Component {
name={name}
mirrored={isMirrored}
actions={actions}
onMount={(videoRef) => {
onVideoItemMount={(videoRef) => {
this.handleCanvasResize();
onMount(cameraId, videoRef);
onVideoItemMount(cameraId, videoRef);
}}
onVideoItemUnmount={onVideoItemUnmount}
swapLayout={swapLayout}
/>
</div>

Some files were not shown because too many files have changed in this diff Show More