Merged upstream develop branch
This commit is contained in:
commit
b99bd3be0c
@ -7,6 +7,7 @@ trait GuestsApp extends GetGuestsWaitingApprovalReqMsgHdlr
|
||||
with GuestsWaitingApprovedMsgHdlr
|
||||
with GuestWaitingLeftMsgHdlr
|
||||
with SetGuestPolicyMsgHdlr
|
||||
with SetGuestLobbyMessageMsgHdlr
|
||||
with GetGuestPolicyReqMsgHdlr {
|
||||
|
||||
this: MeetingActor =>
|
||||
|
@ -10,6 +10,7 @@ object ScreenshareModel {
|
||||
status.voiceConf = ""
|
||||
status.screenshareConf = ""
|
||||
status.timestamp = ""
|
||||
status.hasAudio = false
|
||||
}
|
||||
|
||||
def getScreenshareStarted(status: ScreenshareModel): Boolean = {
|
||||
@ -79,6 +80,14 @@ object ScreenshareModel {
|
||||
def getTimestamp(status: ScreenshareModel): String = {
|
||||
status.timestamp
|
||||
}
|
||||
|
||||
def setHasAudio(status: ScreenshareModel, hasAudio: Boolean): Unit = {
|
||||
status.hasAudio = hasAudio
|
||||
}
|
||||
|
||||
def getHasAudio(status: ScreenshareModel): Boolean = {
|
||||
status.hasAudio
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenshareModel {
|
||||
@ -90,4 +99,5 @@ class ScreenshareModel {
|
||||
private var voiceConf: String = ""
|
||||
private var screenshareConf: String = ""
|
||||
private var timestamp: String = ""
|
||||
private var hasAudio = false
|
||||
}
|
||||
|
@ -25,9 +25,10 @@ trait GetScreenshareStatusReqMsgHdlr {
|
||||
val vidWidth = ScreenshareModel.getScreenshareVideoWidth(liveMeeting.screenshareModel)
|
||||
val vidHeight = ScreenshareModel.getScreenshareVideoHeight(liveMeeting.screenshareModel)
|
||||
val timestamp = ScreenshareModel.getTimestamp(liveMeeting.screenshareModel)
|
||||
val hasAudio = ScreenshareModel.getHasAudio(liveMeeting.screenshareModel)
|
||||
|
||||
val body = ScreenshareRtmpBroadcastStartedEvtMsgBody(voiceConf, screenshareConf,
|
||||
stream, vidWidth, vidHeight, timestamp)
|
||||
stream, vidWidth, vidHeight, timestamp, hasAudio)
|
||||
val event = ScreenshareRtmpBroadcastStartedEvtMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
|
||||
def handle(msg: ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
|
||||
def broadcastEvent(voiceConf: String, screenshareConf: String, stream: String, vidWidth: Int, vidHeight: Int,
|
||||
timestamp: String): BbbCommonEnvCoreMsg = {
|
||||
timestamp: String, hasAudio: Boolean): BbbCommonEnvCoreMsg = {
|
||||
|
||||
val routing = Routing.addMsgToClientRouting(
|
||||
MessageTypes.BROADCAST_TO_MEETING,
|
||||
@ -23,7 +23,7 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
)
|
||||
|
||||
val body = ScreenshareRtmpBroadcastStartedEvtMsgBody(voiceConf, screenshareConf,
|
||||
stream, vidWidth, vidHeight, timestamp)
|
||||
stream, vidWidth, vidHeight, timestamp, hasAudio)
|
||||
val event = ScreenshareRtmpBroadcastStartedEvtMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
@ -41,12 +41,13 @@ trait ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgHdlr {
|
||||
ScreenshareModel.setVoiceConf(liveMeeting.screenshareModel, msg.body.voiceConf)
|
||||
ScreenshareModel.setScreenshareConf(liveMeeting.screenshareModel, msg.body.screenshareConf)
|
||||
ScreenshareModel.setTimestamp(liveMeeting.screenshareModel, msg.body.timestamp)
|
||||
ScreenshareModel.setHasAudio(liveMeeting.screenshareModel, msg.body.hasAudio)
|
||||
|
||||
log.info("START broadcast ALLOWED when isBroadcastingRTMP=false")
|
||||
|
||||
// Notify viewers in the meeting that there's an rtmp stream to view
|
||||
val msgEvent = broadcastEvent(msg.body.voiceConf, msg.body.screenshareConf, msg.body.stream,
|
||||
msg.body.vidWidth, msg.body.vidHeight, msg.body.timestamp)
|
||||
msg.body.vidWidth, msg.body.vidHeight, msg.body.timestamp, msg.body.hasAudio)
|
||||
bus.outGW.send(msgEvent)
|
||||
} else {
|
||||
log.info("START broadcast NOT ALLOWED when isBroadcastingRTMP=true")
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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 =>
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
10
akka-bbb-fsesl/src/debian/DEBIAN/postinst
Normal file
10
akka-bbb-fsesl/src/debian/DEBIAN/postinst
Normal 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
|
@ -83,7 +83,7 @@ public class FreeswitchConferenceEventListener implements ConferenceEventListene
|
||||
if (((ScreenshareRTMPBroadcastEvent) event).getBroadcast()) {
|
||||
ScreenshareRTMPBroadcastEvent evt = (ScreenshareRTMPBroadcastEvent) event;
|
||||
vcs.deskShareRTMPBroadcastStarted(evt.getRoom(), evt.getBroadcastingStreamUrl(),
|
||||
evt.getVideoWidth(), evt.getVideoHeight(), evt.getTimestamp());
|
||||
evt.getVideoWidth(), evt.getVideoHeight(), evt.getTimestamp(), evt.getHasAudio());
|
||||
} else {
|
||||
ScreenshareRTMPBroadcastEvent evt = (ScreenshareRTMPBroadcastEvent) event;
|
||||
vcs.deskShareRTMPBroadcastStopped(evt.getRoom(), evt.getBroadcastingStreamUrl(),
|
||||
|
@ -55,7 +55,8 @@ public interface IVoiceConferenceService {
|
||||
String streamname,
|
||||
Integer videoWidth,
|
||||
Integer videoHeight,
|
||||
String timestamp);
|
||||
String timestamp,
|
||||
boolean hasAudio);
|
||||
|
||||
void deskShareRTMPBroadcastStopped(String room,
|
||||
String streamname,
|
||||
|
@ -25,6 +25,7 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
private String streamUrl;
|
||||
private Integer vw;
|
||||
private Integer vh;
|
||||
private boolean hasAudio;
|
||||
|
||||
private final String SCREENSHARE_SUFFIX = "-SCREENSHARE";
|
||||
|
||||
@ -46,6 +47,10 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
|
||||
public void setVideoHeight(Integer vh) {this.vh = vh;}
|
||||
|
||||
public void setHasAudio(boolean hasAudio) {
|
||||
this.hasAudio = hasAudio;
|
||||
}
|
||||
|
||||
public Integer getVideoHeight() {return vh;}
|
||||
|
||||
public Integer getVideoWidth() {return vw;}
|
||||
@ -65,4 +70,8 @@ public class ScreenshareRTMPBroadcastEvent extends VoiceConferenceEvent {
|
||||
public boolean getBroadcast() {
|
||||
return broadcast;
|
||||
}
|
||||
|
||||
public boolean getHasAudio() {
|
||||
return hasAudio;
|
||||
}
|
||||
}
|
||||
|
@ -237,13 +237,14 @@ class VoiceConferenceService(healthz: HealthzService,
|
||||
streamname: String,
|
||||
vw: java.lang.Integer,
|
||||
vh: java.lang.Integer,
|
||||
timestamp: String
|
||||
timestamp: String,
|
||||
hasAudio: Boolean
|
||||
) {
|
||||
|
||||
val header = BbbCoreVoiceConfHeader(ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg.NAME, voiceConfId)
|
||||
val body = ScreenshareRtmpBroadcastStartedVoiceConfEvtMsgBody(voiceConf = voiceConfId, screenshareConf = voiceConfId,
|
||||
stream = streamname, vidWidth = vw.intValue(), vidHeight = vh.intValue(),
|
||||
timestamp)
|
||||
timestamp, hasAudio)
|
||||
val envelope = BbbCoreEnvelope(ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg.NAME, Map("voiceConf" -> voiceConfId))
|
||||
|
||||
val msg = new ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg(header, body)
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 =>
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
2
bigbluebutton-html5/.gitignore
vendored
2
bigbluebutton-html5/.gitignore
vendored
@ -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
|
||||
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 } },
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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 },
|
||||
|
@ -148,6 +148,7 @@ export default function addMeeting(meeting) {
|
||||
meetingId,
|
||||
meetingEnded,
|
||||
publishedPoll: false,
|
||||
guestLobbyMessage: '',
|
||||
randomlySelectedUser: '',
|
||||
}, flat(newMeeting, {
|
||||
safe: true,
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
import {
|
||||
SFU_CLIENT_SIDE_ERRORS,
|
||||
SFU_SERVER_SIDE_ERRORS
|
||||
} from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
|
||||
|
||||
// Mapped getDisplayMedia errors. These are bridge agnostic
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
|
||||
const GDM_ERRORS = {
|
||||
// Fallback error: 1130
|
||||
1130: 'GetDisplayMediaGenericError',
|
||||
1131: 'AbortError',
|
||||
1132: 'InvalidStateError',
|
||||
1133: 'OverconstrainedError',
|
||||
1134: 'TypeError',
|
||||
1135: 'NotFoundError',
|
||||
1136: 'NotAllowedError',
|
||||
1137: 'NotSupportedError',
|
||||
1138: 'NotReadableError',
|
||||
};
|
||||
|
||||
// Import as many bridge specific errors you want in this utilitary and shove
|
||||
// them into the error class slots down below.
|
||||
const CLIENT_SIDE_ERRORS = {
|
||||
1101: "SIGNALLING_TRANSPORT_DISCONNECTED",
|
||||
1102: "SIGNALLING_TRANSPORT_CONNECTION_FAILED",
|
||||
1104: "SCREENSHARE_PLAY_FAILED",
|
||||
1105: "PEER_NEGOTIATION_FAILED",
|
||||
1107: "ICE_STATE_FAILED",
|
||||
1120: "MEDIA_TIMEOUT",
|
||||
1121: "UNKNOWN_ERROR",
|
||||
};
|
||||
|
||||
const SERVER_SIDE_ERRORS = {
|
||||
...SFU_SERVER_SIDE_ERRORS,
|
||||
}
|
||||
|
||||
const AGGREGATED_ERRORS = {
|
||||
...CLIENT_SIDE_ERRORS,
|
||||
...SERVER_SIDE_ERRORS,
|
||||
...GDM_ERRORS,
|
||||
}
|
||||
|
||||
const expandErrors = () => {
|
||||
const expandedErrors = Object.keys(AGGREGATED_ERRORS).reduce((map, key) => {
|
||||
map[AGGREGATED_ERRORS[key]] = { errorCode: key, errorMessage: AGGREGATED_ERRORS[key] };
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return { ...AGGREGATED_ERRORS, ...expandedErrors };
|
||||
}
|
||||
|
||||
const SCREENSHARING_ERRORS = expandErrors();
|
||||
|
||||
export {
|
||||
GDM_ERRORS,
|
||||
BRIDGE_SERVER_SIDE_ERRORS,
|
||||
BRIDGE_CLIENT_SIDE_ERRORS,
|
||||
// All errors, [code]: [message]
|
||||
// Expanded errors. It's AGGREGATED + message: { errorCode, errorMessage }
|
||||
SCREENSHARING_ERRORS,
|
||||
}
|
@ -1,236 +1,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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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 }, () => {
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -1,209 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import browser from 'browser-detect';
|
||||
import Button from '/imports/ui/components/button/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { notify } from '/imports/ui/services/notification';
|
||||
import cx from 'classnames';
|
||||
import Modal from '/imports/ui/components/modal/simple/component';
|
||||
import { withModalMounter } from '../../modal/service';
|
||||
import { styles } from '../styles';
|
||||
import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
amIPresenter: PropTypes.bool.isRequired,
|
||||
handleShareScreen: PropTypes.func.isRequired,
|
||||
handleUnshareScreen: PropTypes.func.isRequired,
|
||||
isVideoBroadcasting: PropTypes.bool.isRequired,
|
||||
screenSharingCheck: PropTypes.bool.isRequired,
|
||||
screenShareEndAlert: PropTypes.func.isRequired,
|
||||
isMeteorConnected: PropTypes.bool.isRequired,
|
||||
screenshareDataSavingSetting: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
desktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
|
||||
description: 'Desktop Share option label',
|
||||
},
|
||||
lockedDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.lockedDesktopShareLabel',
|
||||
description: 'Desktop locked Share option label',
|
||||
},
|
||||
stopDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareLabel',
|
||||
description: 'Stop Desktop Share option label',
|
||||
},
|
||||
desktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
|
||||
description: 'adds context to desktop share option',
|
||||
},
|
||||
stopDesktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
|
||||
description: 'adds context to stop desktop share option',
|
||||
},
|
||||
genericError: {
|
||||
id: 'app.screenshare.genericError',
|
||||
description: 'error message for when screensharing fails with unknown error',
|
||||
},
|
||||
NotAllowedError: {
|
||||
id: 'app.screenshare.notAllowed',
|
||||
description: 'error message when screen access was not granted',
|
||||
},
|
||||
NotSupportedError: {
|
||||
id: 'app.screenshare.notSupportedError',
|
||||
description: 'error message when trying to share screen in unsafe environments',
|
||||
},
|
||||
screenShareNotSupported: {
|
||||
id: 'app.media.screenshare.notSupported',
|
||||
descriptions: 'error message when trying share screen on unsupported browsers',
|
||||
},
|
||||
screenShareUnavailable: {
|
||||
id: 'app.media.screenshare.unavailable',
|
||||
descriptions: 'title for unavailable screen share modal',
|
||||
},
|
||||
NotReadableError: {
|
||||
id: 'app.screenshare.notReadableError',
|
||||
description: 'error message when the browser failed to capture the screen',
|
||||
},
|
||||
1108: {
|
||||
id: 'app.deskshare.iceConnectionStateError',
|
||||
description: 'Error message for ice connection state failure',
|
||||
},
|
||||
2000: {
|
||||
id: 'app.sfu.mediaServerConnectionError2000',
|
||||
description: 'Error message fired when the SFU cannot connect to the media server',
|
||||
},
|
||||
2001: {
|
||||
id: 'app.sfu.mediaServerOffline2001',
|
||||
description: 'error message when SFU is offline',
|
||||
},
|
||||
2002: {
|
||||
id: 'app.sfu.mediaServerNoResources2002',
|
||||
description: 'Error message fired when the media server lacks disk, CPU or FDs',
|
||||
},
|
||||
2003: {
|
||||
id: 'app.sfu.mediaServerRequestTimeout2003',
|
||||
description: 'Error message fired when requests are timing out due to lack of resources',
|
||||
},
|
||||
2021: {
|
||||
id: 'app.sfu.serverIceGatheringFailed2021',
|
||||
description: 'Error message fired when the server cannot enact ICE gathering',
|
||||
},
|
||||
2022: {
|
||||
id: 'app.sfu.serverIceStateFailed2022',
|
||||
description: 'Error message fired when the server endpoint transitioned to a FAILED ICE state',
|
||||
},
|
||||
2200: {
|
||||
id: 'app.sfu.mediaGenericError2200',
|
||||
description: 'Error message fired when the SFU component generated a generic error',
|
||||
},
|
||||
2202: {
|
||||
id: 'app.sfu.invalidSdp2202',
|
||||
description: 'Error message fired when the clients provides an invalid SDP',
|
||||
},
|
||||
2203: {
|
||||
id: 'app.sfu.noAvailableCodec2203',
|
||||
description: 'Error message fired when the server has no available codec for the client',
|
||||
},
|
||||
});
|
||||
|
||||
const BROWSER_RESULTS = browser();
|
||||
const isMobileBrowser = (BROWSER_RESULTS ? BROWSER_RESULTS.mobile : false)
|
||||
|| (BROWSER_RESULTS && BROWSER_RESULTS.os
|
||||
? BROWSER_RESULTS.os.includes('Android') // mobile flag doesn't always work
|
||||
: false);
|
||||
const IS_SAFARI = BROWSER_RESULTS.name === 'safari';
|
||||
|
||||
const DesktopShare = ({
|
||||
intl,
|
||||
handleShareScreen,
|
||||
handleUnshareScreen,
|
||||
isVideoBroadcasting,
|
||||
amIPresenter,
|
||||
screenSharingCheck,
|
||||
screenShareEndAlert,
|
||||
isMeteorConnected,
|
||||
screenshareDataSavingSetting,
|
||||
mountModal,
|
||||
}) => {
|
||||
// This is the failure callback that will be passed to the /api/screenshare/kurento.js
|
||||
// script on the presenter's call
|
||||
const onFail = (normalizedError) => {
|
||||
const { errorCode, errorMessage, errorReason } = normalizedError;
|
||||
const error = errorCode || errorMessage || errorReason;
|
||||
// We have a properly mapped error for this. Exit screenshare and show a toast notification
|
||||
if (intlMessages[error]) {
|
||||
window.kurentoExitScreenShare();
|
||||
notify(intl.formatMessage(intlMessages[error]), 'error', 'desktop');
|
||||
} else {
|
||||
// Unmapped error. Log it (so we can infer what's going on), close screenSharing
|
||||
// session and display generic error message
|
||||
logger.error({
|
||||
logCode: 'screenshare_default_error',
|
||||
extraInfo: {
|
||||
errorCode, errorMessage, errorReason,
|
||||
},
|
||||
}, 'Default error handler for screenshare');
|
||||
window.kurentoExitScreenShare();
|
||||
notify(intl.formatMessage(intlMessages.genericError), 'error', 'desktop');
|
||||
}
|
||||
// Don't trigger the screen share end alert if presenter click to cancel on screen share dialog
|
||||
if (error !== 'NotAllowedError') {
|
||||
screenShareEndAlert();
|
||||
}
|
||||
};
|
||||
|
||||
const screenshareLocked = screenshareDataSavingSetting
|
||||
? intlMessages.desktopShareLabel : intlMessages.lockedDesktopShareLabel;
|
||||
|
||||
const vLabel = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareLabel : screenshareLocked;
|
||||
|
||||
const vDescr = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
|
||||
|
||||
const shouldAllowScreensharing = screenSharingCheck
|
||||
&& !isMobileBrowser
|
||||
&& amIPresenter;
|
||||
|
||||
return shouldAllowScreensharing
|
||||
? (
|
||||
<Button
|
||||
className={cx(isVideoBroadcasting || styles.btn)}
|
||||
disabled={(!isMeteorConnected && !isVideoBroadcasting) || !screenshareDataSavingSetting}
|
||||
icon={isVideoBroadcasting ? 'desktop' : 'desktop_off'}
|
||||
label={intl.formatMessage(vLabel)}
|
||||
description={intl.formatMessage(vDescr)}
|
||||
color={isVideoBroadcasting ? 'primary' : 'default'}
|
||||
ghost={!isVideoBroadcasting}
|
||||
hideLabel
|
||||
circle
|
||||
size="lg"
|
||||
onClick={isVideoBroadcasting ? handleUnshareScreen : () => {
|
||||
if (IS_SAFARI && !ScreenshareBridgeService.hasDisplayMedia) {
|
||||
return mountModal(<Modal
|
||||
overlayClassName={styles.overlay}
|
||||
className={styles.modal}
|
||||
onRequestClose={() => mountModal(null)}
|
||||
hideBorder
|
||||
contentLabel={intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
>
|
||||
<h3 className={styles.title}>
|
||||
{intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
</h3>
|
||||
<p>{intl.formatMessage(intlMessages.screenShareNotSupported)}</p>
|
||||
</Modal>);
|
||||
}
|
||||
handleShareScreen(onFail);
|
||||
}
|
||||
}
|
||||
id={isVideoBroadcasting ? 'unshare-screen-button' : 'share-screen-button'}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
DesktopShare.propTypes = propTypes;
|
||||
export default withModalMounter(injectIntl(memo(DesktopShare)));
|
209
bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx
Executable file
209
bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx
Executable file
@ -0,0 +1,209 @@
|
||||
import React, { memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import browser from 'browser-detect';
|
||||
import Button from '/imports/ui/components/button/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { notify } from '/imports/ui/services/notification';
|
||||
import cx from 'classnames';
|
||||
import Modal from '/imports/ui/components/modal/simple/component';
|
||||
import { withModalMounter } from '../../modal/service';
|
||||
import { styles } from '../styles';
|
||||
import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
|
||||
import {
|
||||
shareScreen,
|
||||
stop,
|
||||
screenshareHasEnded,
|
||||
screenShareEndAlert,
|
||||
isVideoBroadcasting,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors';
|
||||
|
||||
const BROWSER_RESULTS = browser();
|
||||
const isMobileBrowser = (BROWSER_RESULTS ? BROWSER_RESULTS.mobile : false)
|
||||
|| (BROWSER_RESULTS && BROWSER_RESULTS.os
|
||||
? BROWSER_RESULTS.os.includes('Android') // mobile flag doesn't always work
|
||||
: false);
|
||||
const IS_SAFARI = BROWSER_RESULTS.name === 'safari';
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.objectOf(Object).isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
amIPresenter: PropTypes.bool.isRequired,
|
||||
isVideoBroadcasting: PropTypes.bool.isRequired,
|
||||
isMeteorConnected: PropTypes.bool.isRequired,
|
||||
screenshareDataSavingSetting: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
desktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
|
||||
description: 'Desktop Share option label',
|
||||
},
|
||||
lockedDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.lockedDesktopShareLabel',
|
||||
description: 'Desktop locked Share option label',
|
||||
},
|
||||
stopDesktopShareLabel: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareLabel',
|
||||
description: 'Stop Desktop Share option label',
|
||||
},
|
||||
desktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
|
||||
description: 'adds context to desktop share option',
|
||||
},
|
||||
stopDesktopShareDesc: {
|
||||
id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
|
||||
description: 'adds context to stop desktop share option',
|
||||
},
|
||||
screenShareNotSupported: {
|
||||
id: 'app.media.screenshare.notSupported',
|
||||
descriptions: 'error message when trying share screen on unsupported browsers',
|
||||
},
|
||||
screenShareUnavailable: {
|
||||
id: 'app.media.screenshare.unavailable',
|
||||
descriptions: 'title for unavailable screen share modal',
|
||||
},
|
||||
finalError: {
|
||||
id: 'app.screenshare.screenshareFinalError',
|
||||
description: 'Screen sharing failures with no recovery procedure',
|
||||
},
|
||||
retryError: {
|
||||
id: 'app.screenshare.screenshareRetryError',
|
||||
description: 'Screen sharing failures where a retry is recommended',
|
||||
},
|
||||
retryOtherEnvError: {
|
||||
id: 'app.screenshare.screenshareRetryOtherEnvError',
|
||||
description: 'Screen sharing failures where a retry in another environment is recommended',
|
||||
},
|
||||
unsupportedEnvError: {
|
||||
id: 'app.screenshare.screenshareUnsupportedEnv',
|
||||
description: 'Screen sharing is not supported, changing browser or device is recommended',
|
||||
},
|
||||
permissionError: {
|
||||
id: 'app.screenshare.screensharePermissionError',
|
||||
description: 'Screen sharing failure due to lack of permission',
|
||||
},
|
||||
});
|
||||
|
||||
const getErrorLocale = (errorCode) => {
|
||||
switch (errorCode) {
|
||||
// Denied getDisplayMedia permission error
|
||||
case SCREENSHARING_ERRORS.NotAllowedError.errorCode:
|
||||
return intlMessages.permissionError;
|
||||
// Browser is supposed to be supported, but a browser-related error happening.
|
||||
// Suggest retrying in another device/browser/env
|
||||
case SCREENSHARING_ERRORS.AbortError.errorCode:
|
||||
case SCREENSHARING_ERRORS.InvalidStateError.errorCode:
|
||||
case SCREENSHARING_ERRORS.OverconstrainedError.errorCode:
|
||||
case SCREENSHARING_ERRORS.TypeError.errorCode:
|
||||
case SCREENSHARING_ERRORS.NotFoundError.errorCode:
|
||||
case SCREENSHARING_ERRORS.NotReadableError.errorCode:
|
||||
case SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_NO_AVAILABLE_CODEC.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_INVALID_SDP.errorCode:
|
||||
return intlMessages.retryOtherEnvError;
|
||||
// Fatal errors where a retry isn't warranted. This probably means the server
|
||||
// is misconfigured somehow or the provider is utterly botched, so nothing
|
||||
// the end user can do besides requesting support
|
||||
case SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED.errorCode:
|
||||
case SCREENSHARING_ERRORS.MEDIA_SERVER_CONNECTION_ERROR.errorCode:
|
||||
case SCREENSHARING_ERRORS.SFU_INVALID_REQUEST.errorCode:
|
||||
return intlMessages.finalError;
|
||||
// Unsupported errors
|
||||
case SCREENSHARING_ERRORS.NotSupportedError.errorCode:
|
||||
return intlMessages.unsupportedEnvError;
|
||||
// Fall through: everything else is an error which might be solved with a retry
|
||||
default:
|
||||
return intlMessages.retryError;
|
||||
}
|
||||
}
|
||||
|
||||
const ScreenshareButton = ({
|
||||
intl,
|
||||
enabled,
|
||||
isVideoBroadcasting,
|
||||
amIPresenter,
|
||||
isMeteorConnected,
|
||||
screenshareDataSavingSetting,
|
||||
mountModal,
|
||||
}) => {
|
||||
// This is the failure callback that will be passed to the /api/screenshare/kurento.js
|
||||
// script on the presenter's call
|
||||
const handleFailure = (error) => {
|
||||
const {
|
||||
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
|
||||
errorMessage
|
||||
} = error;
|
||||
|
||||
logger.error({
|
||||
logCode: 'screenshare_failed',
|
||||
extraInfo: { errorCode, errorMessage },
|
||||
}, 'Screenshare failed');
|
||||
|
||||
const localizedError = getErrorLocale(errorCode);
|
||||
notify(intl.formatMessage(localizedError, { 0: errorCode }), 'error', 'desktop');
|
||||
screenshareHasEnded();
|
||||
};
|
||||
|
||||
const renderScreenshareUnavailableModal = () => {
|
||||
return mountModal(
|
||||
<Modal
|
||||
overlayClassName={styles.overlay}
|
||||
className={styles.modal}
|
||||
onRequestClose={() => mountModal(null)}
|
||||
hideBorder
|
||||
contentLabel={intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
>
|
||||
<h3 className={styles.title}>
|
||||
{intl.formatMessage(intlMessages.screenShareUnavailable)}
|
||||
</h3>
|
||||
<p>{intl.formatMessage(intlMessages.screenShareNotSupported)}</p>
|
||||
</Modal>
|
||||
)
|
||||
};
|
||||
|
||||
const screenshareLocked = screenshareDataSavingSetting
|
||||
? intlMessages.desktopShareLabel : intlMessages.lockedDesktopShareLabel;
|
||||
|
||||
const vLabel = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareLabel : screenshareLocked;
|
||||
|
||||
const vDescr = isVideoBroadcasting
|
||||
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
|
||||
|
||||
const shouldAllowScreensharing = enabled
|
||||
&& !isMobileBrowser
|
||||
&& amIPresenter;
|
||||
|
||||
return shouldAllowScreensharing
|
||||
? (
|
||||
<Button
|
||||
className={cx(isVideoBroadcasting || styles.btn)}
|
||||
disabled={(!isMeteorConnected && !isVideoBroadcasting) || !screenshareDataSavingSetting}
|
||||
icon={isVideoBroadcasting ? 'desktop' : 'desktop_off'}
|
||||
label={intl.formatMessage(vLabel)}
|
||||
description={intl.formatMessage(vDescr)}
|
||||
color={isVideoBroadcasting ? 'primary' : 'default'}
|
||||
ghost={!isVideoBroadcasting}
|
||||
hideLabel
|
||||
circle
|
||||
size="lg"
|
||||
onClick={isVideoBroadcasting
|
||||
? screenshareHasEnded
|
||||
: () => {
|
||||
if (IS_SAFARI && !ScreenshareBridgeService.HAS_DISPLAY_MEDIA) {
|
||||
renderScreenshareUnavailableModal();
|
||||
} else {
|
||||
shareScreen(handleFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
id={isVideoBroadcasting ? 'unshare-screen-button' : 'share-screen-button'}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
ScreenshareButton.propTypes = propTypes;
|
||||
export default withModalMounter(injectIntl(memo(ScreenshareButton)));
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { withTracker } from 'meteor/react-meteor-data';
|
||||
import { withModalMounter } from '/imports/ui/components/modal/service';
|
||||
import ScreenshareButton from './component';
|
||||
import getFromUserSettings from '/imports/ui/services/users-settings';
|
||||
import {
|
||||
isVideoBroadcasting,
|
||||
dataSavingSetting,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
|
||||
const ScreenshareButtonContainer = props => <ScreenshareButton {...props} />;
|
||||
|
||||
/*
|
||||
* All props, including the ones that are inherited from actions-bar
|
||||
* isVideoBroadcasting,
|
||||
* amIPresenter,
|
||||
* screenSharingCheck,
|
||||
* isMeteorConnected,
|
||||
* screenshareDataSavingSetting,
|
||||
*/
|
||||
export default withModalMounter(withTracker(({ mountModal }) => ({
|
||||
isVideoBroadcasting: isVideoBroadcasting(),
|
||||
screenshareDataSavingSetting: dataSavingSetting(),
|
||||
enabled: getFromUserSettings(
|
||||
'bbb_enable_screen_sharing',
|
||||
Meteor.settings.public.kurento.enableScreensharing
|
||||
),
|
||||
}))(ScreenshareButtonContainer));
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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 === '') {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}}>
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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 });
|
||||
|
@ -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 (
|
||||
|
@ -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) {
|
||||
|
@ -489,6 +489,7 @@ class WebcamDraggable extends PureComponent {
|
||||
style={{
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
zIndex: 2,
|
||||
display: hideWebcams ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
143
bigbluebutton-html5/imports/ui/components/poll/dragAndDrop/component.jsx
Executable file
143
bigbluebutton-html5/imports/ui/components/poll/dragAndDrop/component.jsx
Executable 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,
|
||||
};
|
26
bigbluebutton-html5/imports/ui/components/poll/dragAndDrop/styles.scss
Executable file
26
bigbluebutton-html5/imports/ui/components/poll/dragAndDrop/styles.scss
Executable 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%;
|
||||
}
|
@ -363,3 +363,8 @@
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dragAndDropPollContainer {
|
||||
width: 200px !important;
|
||||
height: 200px !important;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -7,9 +7,22 @@ import FullscreenButtonContainer from '../fullscreen-button/container';
|
||||
import { styles } from './styles';
|
||||
import AutoplayOverlay from '../media/autoplay-overlay/component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import cx from 'classnames';
|
||||
import playAndRetry from '/imports/utils/mediaElementPlayRetry';
|
||||
import PollingContainer from '/imports/ui/components/polling/container';
|
||||
import { withLayoutConsumer } from '/imports/ui/components/layout/context';
|
||||
import {
|
||||
SCREENSHARE_MEDIA_ELEMENT_NAME,
|
||||
screenshareHasEnded,
|
||||
screenshareHasStarted,
|
||||
getMediaElement,
|
||||
attachLocalPreviewStream,
|
||||
} from '/imports/ui/components/screenshare/service';
|
||||
import {
|
||||
isStreamStateUnhealthy,
|
||||
subscribeToStreamStateChange,
|
||||
unsubscribeFromStreamStateChange,
|
||||
} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
screenShareLabel: {
|
||||
@ -33,49 +46,63 @@ class ScreenshareComponent extends React.Component {
|
||||
loaded: false,
|
||||
isFullscreen: false,
|
||||
autoplayBlocked: false,
|
||||
isStreamHealthy: false,
|
||||
};
|
||||
|
||||
this.onVideoLoad = this.onVideoLoad.bind(this);
|
||||
this.onLoadedData = this.onLoadedData.bind(this);
|
||||
this.onFullscreenChange = this.onFullscreenChange.bind(this);
|
||||
this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
|
||||
this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
|
||||
this.failedMediaElements = [];
|
||||
this.onStreamStateChange = this.onStreamStateChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { presenterScreenshareHasStarted } = this.props;
|
||||
presenterScreenshareHasStarted();
|
||||
|
||||
screenshareHasStarted();
|
||||
this.screenshareContainer.addEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
// Autoplay failure handling
|
||||
window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed);
|
||||
// Stream health state tracker to propagate UI changes on reconnections
|
||||
subscribeToStreamStateChange('screenshare', this.onStreamStateChange);
|
||||
// Attaches the local stream if it exists to serve as the local presenter preview
|
||||
attachLocalPreviewStream(getMediaElement());
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isPresenter, unshareScreen,
|
||||
isPresenter,
|
||||
} = this.props;
|
||||
if (isPresenter && !prevProps.isPresenter) {
|
||||
unshareScreen();
|
||||
screenshareHasEnded();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {
|
||||
presenterScreenshareHasEnded,
|
||||
unshareScreen,
|
||||
getSwapLayout,
|
||||
shouldEnableSwapLayout,
|
||||
toggleSwapLayout,
|
||||
} = this.props;
|
||||
const layoutSwapped = getSwapLayout() && shouldEnableSwapLayout();
|
||||
if (layoutSwapped) toggleSwapLayout();
|
||||
presenterScreenshareHasEnded();
|
||||
unshareScreen();
|
||||
screenshareHasEnded();
|
||||
this.screenshareContainer.removeEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
|
||||
unsubscribeFromStreamStateChange('screenshare', this.onStreamStateChange);
|
||||
}
|
||||
|
||||
onVideoLoad() {
|
||||
onStreamStateChange (event) {
|
||||
const { streamState } = event.detail;
|
||||
const { isStreamHealthy } = this.state;
|
||||
|
||||
const newHealthState = !isStreamStateUnhealthy(streamState);
|
||||
event.stopPropagation();
|
||||
if (newHealthState !== isStreamHealthy) {
|
||||
this.setState({ isStreamHealthy: newHealthState });
|
||||
}
|
||||
}
|
||||
|
||||
onLoadedData() {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
@ -147,12 +174,35 @@ class ScreenshareComponent extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loaded, autoplayBlocked, isFullscreen } = this.state;
|
||||
renderAutoplayOverlay() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
[!loaded
|
||||
<AutoplayOverlay
|
||||
key={_.uniqueId('screenshareAutoplayOverlay')}
|
||||
autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)}
|
||||
autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)}
|
||||
handleAllowAutoplay={this.handleAllowAutoplay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loaded, autoplayBlocked, isFullscreen, isStreamHealthy } = this.state;
|
||||
const { intl, isPresenter, isGloballyBroadcasting } = this.props;
|
||||
|
||||
// Conditions to render the (re)connecting spinner and the unhealthy stream
|
||||
// grayscale:
|
||||
// 1 - The local media tag has not received any stream data yet
|
||||
// 2 - The user is a presenter and the stream wasn't globally broadcasted yet
|
||||
// 3 - The media was loaded, the stream was globally broadcasted BUT the stream
|
||||
// state transitioned to an unhealthy stream. tl;dr: screen sharing reconnection
|
||||
const shouldRenderConnectingState = !loaded
|
||||
|| (isPresenter && !isGloballyBroadcasting)
|
||||
|| !isStreamHealthy && loaded && isGloballyBroadcasting;
|
||||
|
||||
return (
|
||||
[(shouldRenderConnectingState)
|
||||
? (
|
||||
<div
|
||||
key={_.uniqueId('screenshareArea-')}
|
||||
@ -163,29 +213,28 @@ class ScreenshareComponent extends React.Component {
|
||||
: null,
|
||||
!autoplayBlocked
|
||||
? null
|
||||
: (
|
||||
<AutoplayOverlay
|
||||
key={_.uniqueId('screenshareAutoplayOverlay')}
|
||||
autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)}
|
||||
autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)}
|
||||
handleAllowAutoplay={this.handleAllowAutoplay}
|
||||
/>
|
||||
),
|
||||
: (this.renderAutoplayOverlay()),
|
||||
(
|
||||
<div
|
||||
className={styles.screenshareContainer}
|
||||
key="screenshareContainer"
|
||||
ref={(ref) => { this.screenshareContainer = ref; }}
|
||||
>
|
||||
|
||||
{isFullscreen && <PollingContainer />}
|
||||
|
||||
{loaded && this.renderFullscreenButton()}
|
||||
|
||||
<video
|
||||
id="screenshareVideo"
|
||||
key="screenshareVideo"
|
||||
id={SCREENSHARE_MEDIA_ELEMENT_NAME}
|
||||
key={SCREENSHARE_MEDIA_ELEMENT_NAME}
|
||||
style={{ maxHeight: '100%', width: '100%', height: '100%' }}
|
||||
playsInline
|
||||
onLoadedData={this.onVideoLoad}
|
||||
onLoadedData={this.onLoadedData}
|
||||
ref={(ref) => { this.videoTag = ref; }}
|
||||
className={cx({
|
||||
[styles.unhealthyStream]: shouldRenderConnectingState,
|
||||
})}
|
||||
muted
|
||||
/>
|
||||
</div>
|
||||
@ -199,7 +248,4 @@ export default injectIntl(withLayoutConsumer(ScreenshareComponent));
|
||||
ScreenshareComponent.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
isPresenter: PropTypes.bool.isRequired,
|
||||
unshareScreen: PropTypes.func.isRequired,
|
||||
presenterScreenshareHasEnded: PropTypes.func.isRequired,
|
||||
presenterScreenshareHasStarted: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -4,14 +4,13 @@ import Users from '/imports/api/users/';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import MediaService, { getSwapLayout, shouldEnableSwapLayout } from '/imports/ui/components/media/service';
|
||||
import {
|
||||
isVideoBroadcasting, presenterScreenshareHasEnded, unshareScreen,
|
||||
presenterScreenshareHasStarted,
|
||||
isVideoBroadcasting,
|
||||
isGloballyBroadcasting,
|
||||
} from './service';
|
||||
import ScreenshareComponent from './component';
|
||||
|
||||
const ScreenshareContainer = (props) => {
|
||||
const { isVideoBroadcasting: isVB } = props;
|
||||
if (isVB()) {
|
||||
if (isVideoBroadcasting()) {
|
||||
return <ScreenshareComponent {...props} />;
|
||||
}
|
||||
return null;
|
||||
@ -20,11 +19,8 @@ const ScreenshareContainer = (props) => {
|
||||
export default withTracker(() => {
|
||||
const user = Users.findOne({ userId: Auth.userID }, { fields: { presenter: 1 } });
|
||||
return {
|
||||
isGloballyBroadcasting: isGloballyBroadcasting(),
|
||||
isPresenter: user.presenter,
|
||||
unshareScreen,
|
||||
isVideoBroadcasting,
|
||||
presenterScreenshareHasStarted,
|
||||
presenterScreenshareHasEnded,
|
||||
getSwapLayout,
|
||||
shouldEnableSwapLayout,
|
||||
toggleSwapLayout: MediaService.toggleSwapLayout,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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%);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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(),
|
||||
};
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user