diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserConnectedToGlobalAudioMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserConnectedToGlobalAudioMsgHdlr.scala index bc938437c07663a8f70a7bc9d6ba1e0bd7518adb..937ebd1cc0b65ae8f38992e502a1345780535e75 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserConnectedToGlobalAudioMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserConnectedToGlobalAudioMsgHdlr.scala @@ -40,7 +40,9 @@ trait UserConnectedToGlobalAudioMsgHdlr { talking = false, listenOnly = true, "kms", - System.currentTimeMillis() + System.currentTimeMillis(), + floor = false, + lastFloorTime = "0", ) VoiceUsers.add(liveMeeting.voiceUsers, vu) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/AudioFloorChangedVoiceConfEvtMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/AudioFloorChangedVoiceConfEvtMsgHdlr.scala new file mode 100644 index 0000000000000000000000000000000000000000..eaeea145a0c50d9c9336891a169ea92b106f531e --- /dev/null +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/AudioFloorChangedVoiceConfEvtMsgHdlr.scala @@ -0,0 +1,61 @@ +package org.bigbluebutton.core.apps.voice + +import org.bigbluebutton.common2.msgs._ +import org.bigbluebutton.core.models.{ VoiceUserState, VoiceUsers } +import org.bigbluebutton.core.running.{ BaseMeetingActor, LiveMeeting, OutMsgRouter } + +trait AudioFloorChangedVoiceConfEvtMsgHdlr { + this: BaseMeetingActor => + + val liveMeeting: LiveMeeting + val outGW: OutMsgRouter + + def handleAudioFloorChangedVoiceConfEvtMsg(msg: AudioFloorChangedVoiceConfEvtMsg): Unit = { + + def broadcastEvent(vu: VoiceUserState): Unit = { + val routing = Routing.addMsgToClientRouting( + MessageTypes.BROADCAST_TO_MEETING, + liveMeeting.props.meetingProp.intId, + vu.intId + ) + val envelope = BbbCoreEnvelope(AudioFloorChangedEvtMsg.NAME, routing) + val header = BbbClientMsgHeader( + AudioFloorChangedEvtMsg.NAME, + liveMeeting.props.meetingProp.intId, vu.intId + ) + + val body = AudioFloorChangedEvtMsgBody( + voiceConf = msg.header.voiceConf, + intId = vu.intId, + voiceUserId = vu.voiceUserId, + floor = vu.floor, + lastFloorTime = msg.body.floorTimestamp + ) + + val event = AudioFloorChangedEvtMsg(header, body) + val msgEvent = BbbCommonEnvCoreMsg(envelope, event) + outGW.send(msgEvent) + } + + for { + oldFloorUser <- VoiceUsers.releasedFloor( + liveMeeting.voiceUsers, + msg.body.oldVoiceUserId, + floor = false + ) + } yield { + broadcastEvent(oldFloorUser) + } + + for { + newFloorUser <- VoiceUsers.becameFloor( + liveMeeting.voiceUsers, + msg.body.voiceUserId, + true, + msg.body.floorTimestamp + ) + } yield { + broadcastEvent(newFloorUser) + } + } +} diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp.scala index 4a49d961f1e4155bcf0f983a75b945551e08815e..e0ba15cdc9787795907ed5716214dc7830144bdb 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp.scala @@ -268,7 +268,9 @@ object VoiceApp extends SystemConfiguration { talking, listenOnly = isListenOnly, callingInto, - System.currentTimeMillis() + System.currentTimeMillis(), + floor = false, + lastFloorTime = "0" ) VoiceUsers.add(liveMeeting.voiceUsers, voiceUserState) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp2x.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp2x.scala index c5f0662066d8730f4da62c12e9094d3f885c1697..409f29112aac27ea67da50fa89bc8e9b8cb46ea1 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp2x.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/VoiceApp2x.scala @@ -18,6 +18,7 @@ trait VoiceApp2x extends UserJoinedVoiceConfEvtMsgHdlr with RecordingStartedVoiceConfEvtMsgHdlr with VoiceConfRunningEvtMsgHdlr with SyncGetVoiceUsersMsgHdlr + with AudioFloorChangedVoiceConfEvtMsgHdlr with VoiceConfCallStateEvtMsgHdlr with UserStatusVoiceConfEvtMsgHdlr { diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/VoiceUsers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/VoiceUsers.scala index 58cfb7dcdcc1d8466331eb11db8419726420fd23..ee9d528fe11a36e249fb607cf876ada6bf695a55 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/VoiceUsers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/VoiceUsers.scala @@ -67,6 +67,29 @@ object VoiceUsers { } } + def becameFloor(users: VoiceUsers, voiceUserId: String, floor: Boolean, timestamp: String): Option[VoiceUserState] = { + for { + u <- findWithVoiceUserId(users, voiceUserId) + } yield { + val vu = u.modify(_.floor).setTo(floor) + .modify(_.lastFloorTime).setTo(timestamp) + .modify(_.lastStatusUpdateOn).setTo(System.currentTimeMillis()) + users.save(vu) + vu + } + } + + def releasedFloor(users: VoiceUsers, voiceUserId: String, floor: Boolean): Option[VoiceUserState] = { + for { + u <- findWithVoiceUserId(users, voiceUserId) + } yield { + val vu = u.modify(_.floor).setTo(floor) + .modify(_.lastStatusUpdateOn).setTo(System.currentTimeMillis()) + users.save(vu) + vu + } + } + def setLastStatusUpdate(users: VoiceUsers, user: VoiceUserState): VoiceUserState = { val vu = user.copy(lastStatusUpdateOn = System.currentTimeMillis()) users.save(vu) @@ -130,16 +153,18 @@ case class VoiceUser2x( voiceUserId: String ) case class VoiceUserVO2x( - intId: String, - voiceUserId: String, - callerName: String, - callerNum: String, - joined: Boolean, - locked: Boolean, - muted: Boolean, - talking: Boolean, - callingWith: String, - listenOnly: Boolean + intId: String, + voiceUserId: String, + callerName: String, + callerNum: String, + joined: Boolean, + locked: Boolean, + muted: Boolean, + talking: Boolean, + callingWith: String, + listenOnly: Boolean, + floor: Boolean, + lastFloorTime: String ) case class VoiceUserState( @@ -152,5 +177,7 @@ case class VoiceUserState( talking: Boolean, listenOnly: Boolean, calledInto: String, - lastStatusUpdateOn: Long + lastStatusUpdateOn: Long, + floor: Boolean, + lastFloorTime: String ) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala index fb9e3862dcb8b28ceaf5ea50fc7f21143df0ab20..185836b8468a60d5f9f40efaaa86f4b7a99b4ddb 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala @@ -167,6 +167,8 @@ class ReceivedJsonMsgHandlerActor( routeGenericMsg[MuteMeetingCmdMsg](envelope, jsonNode) case IsMeetingMutedReqMsg.NAME => routeGenericMsg[IsMeetingMutedReqMsg](envelope, jsonNode) + case AudioFloorChangedVoiceConfEvtMsg.NAME => + routeVoiceMsg[AudioFloorChangedVoiceConfEvtMsg](envelope, jsonNode) case CheckRunningAndRecordingVoiceConfEvtMsg.NAME => routeVoiceMsg[CheckRunningAndRecordingVoiceConfEvtMsg](envelope, jsonNode) case UserStatusVoiceConfEvtMsg.NAME => diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala index cf707d2dd6d9961dc3252dd739eff608440feac1..adcea570d7c4eeaafc0fbc130969a36b66884e35 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala @@ -381,9 +381,10 @@ class MeetingActor( case m: UserTalkingInVoiceConfEvtMsg => updateVoiceUserLastActivity(m.body.voiceUserId) handleUserTalkingInVoiceConfEvtMsg(m) - case m: VoiceConfCallStateEvtMsg => handleVoiceConfCallStateEvtMsg(m) + case m: VoiceConfCallStateEvtMsg => handleVoiceConfCallStateEvtMsg(m) - case m: RecordingStartedVoiceConfEvtMsg => handleRecordingStartedVoiceConfEvtMsg(m) + case m: RecordingStartedVoiceConfEvtMsg => handleRecordingStartedVoiceConfEvtMsg(m) + case m: AudioFloorChangedVoiceConfEvtMsg => handleAudioFloorChangedVoiceConfEvtMsg(m) case m: MuteUserCmdMsg => usersApp.handleMuteUserCmdMsg(m) updateUserLastActivity(m.body.mutedBy) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/testdata/FakeUserGenerator.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/testdata/FakeUserGenerator.scala index 9c22ee01201d35ecdb9cd2642e9238bcc4d22b98..7b7f62a6d3a5042e02ffd13fc75a57d500f698b2 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/testdata/FakeUserGenerator.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/testdata/FakeUserGenerator.scala @@ -60,19 +60,21 @@ object FakeUserGenerator { } def createFakeVoiceUser(user: RegisteredUser, callingWith: String, muted: Boolean, talking: Boolean, - listenOnly: Boolean): VoiceUserState = { + listenOnly: Boolean, floor: Boolean = false): VoiceUserState = { val voiceUserId = RandomStringGenerator.randomAlphanumericString(8) + val lastFloorTime = System.currentTimeMillis().toString(); VoiceUserState(intId = user.id, voiceUserId = voiceUserId, callingWith, callerName = user.name, - callerNum = user.name, muted, talking, listenOnly, "freeswitch", System.currentTimeMillis()) + callerNum = user.name, muted, talking, listenOnly, "freeswitch", System.currentTimeMillis(), floor, lastFloorTime) } def createFakeVoiceOnlyUser(callingWith: String, muted: Boolean, talking: Boolean, - listenOnly: Boolean): VoiceUserState = { + listenOnly: Boolean, floor: Boolean = false): VoiceUserState = { val voiceUserId = RandomStringGenerator.randomAlphanumericString(8) val intId = "v_" + RandomStringGenerator.randomAlphanumericString(16) val name = getRandomElement(firstNames, random) + " " + getRandomElement(lastNames, random) + val lastFloorTime = System.currentTimeMillis().toString(); VoiceUserState(intId, voiceUserId = voiceUserId, callingWith, callerName = name, - callerNum = name, muted, talking, listenOnly, "freeswitch", System.currentTimeMillis()) + callerNum = name, muted, talking, listenOnly, "freeswitch", System.currentTimeMillis(), floor, lastFloorTime) } def createFakeWebcamStreamFor(userId: String, viewers: Set[String]): WebcamStream = { diff --git a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/FreeswitchConferenceEventListener.java b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/FreeswitchConferenceEventListener.java index 0242b524e0501582e6007c0b506dfea901d46a4c..33b37f0e7fbf7eae2890aa5ba8df2d790327637d 100755 --- a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/FreeswitchConferenceEventListener.java +++ b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/FreeswitchConferenceEventListener.java @@ -89,6 +89,14 @@ public class FreeswitchConferenceEventListener implements ConferenceEventListene vcs.deskShareRTMPBroadcastStopped(evt.getRoom(), evt.getBroadcastingStreamUrl(), evt.getVideoWidth(), evt.getVideoHeight(), evt.getTimestamp()); } + } else if (event instanceof AudioFloorChangedEvent) { + AudioFloorChangedEvent evt = (AudioFloorChangedEvent) event; + vcs.audioFloorChanged( + evt.getRoom(), + evt.getVoiceUserId(), + evt.getOldVoiceUserId(), + evt.getFloorTimestamp() + ); } else if (event instanceof VoiceConfRunningAndRecordingEvent) { VoiceConfRunningAndRecordingEvent evt = (VoiceConfRunningAndRecordingEvent) event; if (evt.running && ! evt.recording) { diff --git a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/IVoiceConferenceService.java b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/IVoiceConferenceService.java index c60c26984314f2e73e84728a76116adccd6bda1a..3dd8fc5af2426e9fda94f55c890ef36730854960 100755 --- a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/IVoiceConferenceService.java +++ b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/IVoiceConferenceService.java @@ -64,6 +64,11 @@ public interface IVoiceConferenceService { Integer videoHeight, String timestamp); + void audioFloorChanged(String room, + String voiceUserId, + String oldVoiceUserId, + String floorTimestamp); + void voiceConfRunningAndRecording(String room, Boolean isRunning, Boolean isRecording, diff --git a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/events/AudioFloorChangedEvent.java b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/events/AudioFloorChangedEvent.java new file mode 100644 index 0000000000000000000000000000000000000000..ede258c81aa2da7305938a251abe4a6679681f29 --- /dev/null +++ b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/events/AudioFloorChangedEvent.java @@ -0,0 +1,50 @@ +/** +* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ +* +* Copyright (c) 2018 BigBlueButton Inc. and by respective authors (see below). +* +* This program is free software; you can redistribute it and/or modify it under the +* terms of the GNU Lesser General Public License as published by the Free Software +* Foundation; either version 3.0 of the License, or (at your option) any later +* version. +* +* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY +* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License along +* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. +* +*/ +package org.bigbluebutton.freeswitch.voice.events; + +public class AudioFloorChangedEvent extends VoiceConferenceEvent { + + private final String voiceUserId; + private final String oldVoiceUserId; + private final String floorTimestamp; + + public AudioFloorChangedEvent( + String room, + String voiceUserId, + String oldVoiceUserId, + String floorTimestamp + ) { + super(room); + this.voiceUserId = voiceUserId; + this.oldVoiceUserId = oldVoiceUserId; + this.floorTimestamp = floorTimestamp; + } + + public String getVoiceUserId() { + return voiceUserId; + } + + public String getOldVoiceUserId() { + return oldVoiceUserId; + } + + public String getFloorTimestamp() { + return floorTimestamp; + } +} diff --git a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/freeswitch/ESLEventListener.java b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/freeswitch/ESLEventListener.java index c42e80f7be42b2d925ff3baadea1baddad0c8180..5711927f21a9a80b57027e403cba0fbf8be73c1b 100755 --- a/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/freeswitch/ESLEventListener.java +++ b/akka-bbb-fsesl/src/main/java/org/bigbluebutton/freeswitch/voice/freeswitch/ESLEventListener.java @@ -26,6 +26,7 @@ public class ESLEventListener implements IEslEventListener { private static final String STOP_RECORDING_EVENT = "stop-recording"; private static final String CONFERENCE_CREATED_EVENT = "conference-create"; private static final String CONFERENCE_DESTROYED_EVENT = "conference-destroy"; + private static final String FLOOR_CHANGE_EVENT = "video-floor-change"; private static final String SCREENSHARE_CONFERENCE_NAME_SUFFIX = "-SCREENSHARE"; @@ -197,6 +198,12 @@ public class ESLEventListener implements IEslEventListener { } else if (action.equals(CONFERENCE_DESTROYED_EVENT)) { VoiceConfRunningEvent pt = new VoiceConfRunningEvent(confName, false); conferenceEventListener.handleConferenceEvent(pt); + } else if (action.equals(FLOOR_CHANGE_EVENT)) { + String holderMemberId = this.getNewFloorHolderMemberIdFromEvent(event); + String oldHolderMemberId = this.getOldFloorHolderMemberIdFromEvent(event); + String floorTimestamp = event.getEventHeaders().get("Event-Date-Timestamp"); + AudioFloorChangedEvent vFloor= new AudioFloorChangedEvent(confName, holderMemberId, oldHolderMemberId, floorTimestamp); + conferenceEventListener.handleConferenceEvent(vFloor); } else { log.warn("Unknown conference Action [" + action + "]"); } @@ -507,6 +514,22 @@ public class ESLEventListener implements IEslEventListener { return e.getEventHeaders().get("Path"); } + private String getOldFloorHolderMemberIdFromEvent(EslEvent e) { + String oldFloorHolder = e.getEventHeaders().get("Old-ID"); + if(oldFloorHolder == null || oldFloorHolder.equalsIgnoreCase("none")) { + oldFloorHolder= ""; + } + return oldFloorHolder; + } + + private String getNewFloorHolderMemberIdFromEvent(EslEvent e) { + String newHolder = e.getEventHeaders().get("New-ID"); + if(newHolder == null || newHolder.equalsIgnoreCase("none")) { + newHolder = ""; + } + return newHolder; + } + // Distinguish between recording to a file: // /path/to/a/file.mp4 // and broadcasting a stream: diff --git a/akka-bbb-fsesl/src/main/scala/org/bigbluebutton/freeswitch/VoiceConferenceService.scala b/akka-bbb-fsesl/src/main/scala/org/bigbluebutton/freeswitch/VoiceConferenceService.scala index c6da363f8c92b878ec8fa31ca50536ac18f5a703..2194688cc87495921caebd5bcfe74db6909f1044 100755 --- a/akka-bbb-fsesl/src/main/scala/org/bigbluebutton/freeswitch/VoiceConferenceService.scala +++ b/akka-bbb-fsesl/src/main/scala/org/bigbluebutton/freeswitch/VoiceConferenceService.scala @@ -276,6 +276,28 @@ class VoiceConferenceService(healthz: HealthzService, sender.publish(fromVoiceConfRedisChannel, json) } + def audioFloorChanged( + voiceConfId: String, + voiceUserId: String, + oldVoiceUserId: String, + floorTimestamp: String + ) { + val header = BbbCoreVoiceConfHeader(AudioFloorChangedVoiceConfEvtMsg.NAME, voiceConfId) + val body = AudioFloorChangedVoiceConfEvtMsgBody( + voiceConfId, + voiceUserId, + oldVoiceUserId, + floorTimestamp + ); + val envelope = BbbCoreEnvelope(AudioFloorChangedVoiceConfEvtMsg.NAME, Map("voiceConf" -> voiceConfId)) + + val msg = new AudioFloorChangedVoiceConfEvtMsg(header, body) + val msgEvent = BbbCommonEnvCoreMsg(envelope, msg) + + val json = JsonUtil.toJson(msgEvent) + sender.publish(fromVoiceConfRedisChannel, json) + } + def voiceCallStateEvent( conf: String, callSession: String, diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala index a6d15a7ffa19d4d5a6b6ca3af0d3bcb8a71e3f77..7c9fa7b61b8970d4ecf2e1bcab9b8dfeed6ee00b 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/VoiceConfMsgs.scala @@ -495,6 +495,24 @@ object SyncGetVoiceUsersRespMsg { val NAME = "SyncGetVoiceUsersRespMsg" } case class SyncGetVoiceUsersRespMsg(header: BbbClientMsgHeader, body: SyncGetVoiceUsersRespMsgBody) extends BbbCoreMsg case class SyncGetVoiceUsersRespMsgBody(voiceUsers: Vector[VoiceConfUser]) +/** + * Received from FS that a user has become a floor holder + */ +object AudioFloorChangedVoiceConfEvtMsg { val NAME = "AudioFloorChangedVoiceConfEvtMsg" } +case class AudioFloorChangedVoiceConfEvtMsg( + header: BbbCoreVoiceConfHeader, + body: AudioFloorChangedVoiceConfEvtMsgBody +) extends VoiceStandardMsg +case class AudioFloorChangedVoiceConfEvtMsgBody(voiceConf: String, voiceUserId: String, oldVoiceUserId: String, floorTimestamp: String) + +/** + * Sent to a client that an user has become a floor holder + */ + +object AudioFloorChangedEvtMsg { val NAME = "AudioFloorChangedEvtMsg" } +case class AudioFloorChangedEvtMsg(header: BbbClientMsgHeader, body: AudioFloorChangedEvtMsgBody) extends BbbCoreMsg +case class AudioFloorChangedEvtMsgBody(voiceConf: String, intId: String, voiceUserId: String, floor: Boolean, lastFloorTime: String) + /** * Received from FS call state events. */ diff --git a/bbb-fsesl-client/src/main/java/org/freeswitch/esl/client/inbound/Client.java b/bbb-fsesl-client/src/main/java/org/freeswitch/esl/client/inbound/Client.java index dc5d0f6d46a9d83761d37a283015f96e37b94237..e09669031f57b8502fe2dbab39d13400d3df6c02 100755 --- a/bbb-fsesl-client/src/main/java/org/freeswitch/esl/client/inbound/Client.java +++ b/bbb-fsesl-client/src/main/java/org/freeswitch/esl/client/inbound/Client.java @@ -519,6 +519,12 @@ public class Client } else if (eventFunc.equals("conference_loop_input")) { listener.conferenceEventAction(uniqueId, confName, confSize, eventAction, event); return; + } else if (eventFunc.equals("conference_member_set_floor_holder")) { + listener.conferenceEventAction(uniqueId, confName, confSize, eventAction, event); + return; + } else if (eventFunc.equals("conference_video_set_floor_holder")) { + listener.conferenceEventAction(uniqueId, confName, confSize, eventAction, event); + return; } else if (eventFunc.equals("stop_talking_handler")) { listener.conferenceEventAction(uniqueId, confName, confSize, eventAction, event); return; diff --git a/bigbluebutton-html5/imports/api/video-streams/server/eventHandlers.js b/bigbluebutton-html5/imports/api/video-streams/server/eventHandlers.js index 0e773b08eafe12ba8138ab8523b8a4c2e5196a82..068433b2033641dc155b258f5d222cb382ad0546 100644 --- a/bigbluebutton-html5/imports/api/video-streams/server/eventHandlers.js +++ b/bigbluebutton-html5/imports/api/video-streams/server/eventHandlers.js @@ -1,6 +1,8 @@ import RedisPubSub from '/imports/startup/server/redis'; import handleUserSharedHtml5Webcam from './handlers/userSharedHtml5Webcam'; import handleUserUnsharedHtml5Webcam from './handlers/userUnsharedHtml5Webcam'; +import handleFloorChanged from './handlers/floorChanged'; RedisPubSub.on('UserBroadcastCamStartedEvtMsg', handleUserSharedHtml5Webcam); RedisPubSub.on('UserBroadcastCamStoppedEvtMsg', handleUserUnsharedHtml5Webcam); +RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChanged); diff --git a/bigbluebutton-html5/imports/api/video-streams/server/handlers/floorChanged.js b/bigbluebutton-html5/imports/api/video-streams/server/handlers/floorChanged.js new file mode 100644 index 0000000000000000000000000000000000000000..fa662d570108c0c6de274295e98a11721b280d5e --- /dev/null +++ b/bigbluebutton-html5/imports/api/video-streams/server/handlers/floorChanged.js @@ -0,0 +1,12 @@ +import { check } from 'meteor/check'; +import floorChanged from '../modifiers/floorChanged'; + +export default function handleFloorChanged({ header, body }, meetingId) { + const { intId, floor, lastFloorTime } = body; + check(meetingId, String); + check(intId, String); + check(floor, Boolean); + check(lastFloorTime, String); + + return floorChanged(meetingId, intId, floor, lastFloorTime); +} diff --git a/bigbluebutton-html5/imports/api/video-streams/server/modifiers/floorChanged.js b/bigbluebutton-html5/imports/api/video-streams/server/modifiers/floorChanged.js new file mode 100644 index 0000000000000000000000000000000000000000..e59316d299fd4e05b9ae24d56a1bec7b3b8d2f12 --- /dev/null +++ b/bigbluebutton-html5/imports/api/video-streams/server/modifiers/floorChanged.js @@ -0,0 +1,32 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import { check } from 'meteor/check'; + +export default function floorChanged(meetingId, userId, floor, lastFloorTime) { + check(meetingId, String); + check(userId, String); + check(floor, Boolean); + check(lastFloorTime, String); + + const selector = { + meetingId, + userId, + } + + const modifier = { + $set: { + floor, + lastFloorTime: floor ? lastFloorTime : undefined, + }, + }; + + try { + const numberAffected = VideoStreams.update(selector, modifier); + + if (numberAffected) { + Logger.info(`Updated user streams floor times userId=${userId} floor=${floor} lastFloorTime=${lastFloorTime}`); + } + } catch (error) { + return Logger.error(`Error updating stream floor status: ${error}`); + } +} diff --git a/bigbluebutton-html5/imports/api/video-streams/server/modifiers/sharedWebcam.js b/bigbluebutton-html5/imports/api/video-streams/server/modifiers/sharedWebcam.js index 6be33926f742b29439a2cd156b7f5df30b16c690..4725484bb779ee4170ecf9bac707ddc5ce807da5 100644 --- a/bigbluebutton-html5/imports/api/video-streams/server/modifiers/sharedWebcam.js +++ b/bigbluebutton-html5/imports/api/video-streams/server/modifiers/sharedWebcam.js @@ -5,6 +5,9 @@ import { getDeviceId, getUserName, } from '/imports/api/video-streams/server/helpers'; +import VoiceUsers from '/imports/api/voice-users/'; + +const BASE_FLOOR_TIME = "0"; export default function sharedWebcam(meetingId, userId, stream) { check(meetingId, String); @@ -13,6 +16,12 @@ export default function sharedWebcam(meetingId, userId, stream) { const deviceId = getDeviceId(stream); const name = getUserName(userId); + const vu = VoiceUsers.findOne( + { meetingId, intId: userId }, + { fields: { floor: 1, lastFloorTime: 1 }} + ) || {}; + const floor = vu.floor || false; + const lastFloorTime = vu.lastFloorTime || BASE_FLOOR_TIME; const selector = { meetingId, @@ -24,6 +33,8 @@ export default function sharedWebcam(meetingId, userId, stream) { $set: { stream, name, + lastFloorTime, + floor, }, }; diff --git a/bigbluebutton-html5/imports/api/voice-users/server/eventHandlers.js b/bigbluebutton-html5/imports/api/voice-users/server/eventHandlers.js index 6fc5a6dcdefc83becd0c09ed16454aad2f99ec59..1155ace8d268ce5ae1582b3da3975c177e020779 100755 --- a/bigbluebutton-html5/imports/api/voice-users/server/eventHandlers.js +++ b/bigbluebutton-html5/imports/api/voice-users/server/eventHandlers.js @@ -7,6 +7,7 @@ import handleMutedVoiceUser from './handlers/mutedVoiceUser'; import handleGetVoiceUsers from './handlers/getVoiceUsers'; import handleVoiceUsers from './handlers/voiceUsers'; import handleMeetingMuted from './handlers/meetingMuted'; +import handleFloorChange from './handlers/floorChanged'; RedisPubSub.on('UserLeftVoiceConfToClientEvtMsg', handleLeftVoiceUser); RedisPubSub.on('UserJoinedVoiceConfToClientEvtMsg', handleJoinVoiceUser); @@ -15,3 +16,4 @@ RedisPubSub.on('UserMutedVoiceEvtMsg', handleMutedVoiceUser); RedisPubSub.on('GetVoiceUsersMeetingRespMsg', processForHTML5ServerOnly(handleGetVoiceUsers)); RedisPubSub.on('SyncGetVoiceUsersRespMsg', handleVoiceUsers); RedisPubSub.on('MeetingMutedEvtMsg', handleMeetingMuted); +RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChange); diff --git a/bigbluebutton-html5/imports/api/voice-users/server/handlers/floorChanged.js b/bigbluebutton-html5/imports/api/voice-users/server/handlers/floorChanged.js new file mode 100644 index 0000000000000000000000000000000000000000..88326cd187bbaa440b085d6c233e29a39b408513 --- /dev/null +++ b/bigbluebutton-html5/imports/api/voice-users/server/handlers/floorChanged.js @@ -0,0 +1,10 @@ +import { check } from 'meteor/check'; +import updateVoiceUser from '../modifiers/updateVoiceUser'; + +export default function handleFloorChange({ header, body }, meetingId) { + const voiceUser = body; + + check(meetingId, String); + + return updateVoiceUser(meetingId, voiceUser); +} diff --git a/bigbluebutton-html5/imports/api/voice-users/server/modifiers/updateVoiceUser.js b/bigbluebutton-html5/imports/api/voice-users/server/modifiers/updateVoiceUser.js index 270ac5b8a496f5de0bd1e92f1b0df8088a15abb6..2c510d5fb63d6c63cff33f37b35bd3d5f9991de4 100644 --- a/bigbluebutton-html5/imports/api/voice-users/server/modifiers/updateVoiceUser.js +++ b/bigbluebutton-html5/imports/api/voice-users/server/modifiers/updateVoiceUser.js @@ -15,6 +15,8 @@ export default function updateVoiceUser(meetingId, voiceUser) { muted: Match.Maybe(Boolean), voiceConf: String, joined: Match.Maybe(Boolean), + floor: Match.Maybe(Boolean), + lastFloorTime: Match.Maybe(String), }); const { intId } = voiceUser; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index 387c534933da4f353ca8034991727c9d483105d7..dbe8cdb59360331a4437c4b866c6a335ea7d1805 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -700,4 +700,5 @@ export default { amIPresenter, getUsersProp, getUserCount, + sortUsersByCurrent, }; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 0f0573223a7672cafd7344ffc6d325519fb0b643..f7ae96b461e3bc3fff89bdc3b99be0f2f856c83a 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -162,8 +162,8 @@ class VideoProvider extends Component { VideoService.exitVideo(); - Object.keys(this.webRtcPeers).forEach((cameraId) => { - this.stopWebRTCPeer(cameraId, false); + Object.keys(this.webRtcPeers).forEach((stream) => { + this.stopWebRTCPeer(stream, false); }); // Close websocket connection to prevent multiple reconnects from happening @@ -243,29 +243,29 @@ class VideoProvider extends Component { } getStreamsToConnectAndDisconnect(streams) { - const streamsCameraIds = streams.map(s => s.cameraId); + const streamsCameraIds = streams.map(s => s.stream); const streamsConnected = Object.keys(this.webRtcPeers); - const streamsToConnect = streamsCameraIds.filter( - cameraId => !streamsConnected.includes(cameraId), - ); + const streamsToConnect = streamsCameraIds.filter(stream => { + return !streamsConnected.includes(stream); + }); - const streamsToDisconnect = streamsConnected.filter( - cameraId => !streamsCameraIds.includes(cameraId), - ); + const streamsToDisconnect = streamsConnected.filter(stream => { + return !streamsCameraIds.includes(stream); + }); return [streamsToConnect, streamsToDisconnect]; } connectStreams(streamsToConnect) { - streamsToConnect.forEach((cameraId) => { - const isLocal = VideoService.isLocalStream(cameraId); - this.createWebRTCPeer(cameraId, isLocal); + streamsToConnect.forEach((stream) => { + const isLocal = VideoService.isLocalStream(stream); + this.createWebRTCPeer(stream, isLocal); }); } disconnectStreams(streamsToDisconnect) { - streamsToDisconnect.forEach(cameraId => this.stopWebRTCPeer(cameraId, false)); + streamsToDisconnect.forEach(stream => this.stopWebRTCPeer(stream, false)); } updateStreams(streams, shouldDebounce = false) { @@ -280,7 +280,8 @@ class VideoProvider extends Component { this.disconnectStreams(streamsToDisconnect); if (CAMERA_QUALITY_THRESHOLDS_ENABLED) { - this.updateThreshold(this.props.totalNumberOfStreams); + const floorStream = streams.find(vs => vs.floor === true); + this.updateThreshold(this.props.totalNumberOfStreams, floorStream); } } @@ -315,21 +316,21 @@ class VideoProvider extends Component { return this.ws.readyState === WebSocket.OPEN; } - processOutboundIceQueue(peer, role, cameraId) { - const queue = this.outboundIceQueues[cameraId]; + processOutboundIceQueue(peer, role, stream) { + const queue = this.outboundIceQueues[stream]; while (queue && queue.length) { const candidate = queue.shift(); - this.sendIceCandidateToSFU(peer, role, candidate, cameraId); + this.sendIceCandidateToSFU(peer, role, candidate, stream); } } startResponse(message) { - const { cameraId, role } = message; - const peer = this.webRtcPeers[cameraId]; + const { cameraId: stream, role } = message; + const peer = this.webRtcPeers[stream]; logger.debug({ logCode: 'video_provider_start_response_success', - extraInfo: { cameraId, role }, + extraInfo: { cameraId: stream, role }, }, `Camera start request accepted by SFU. Role: ${role}`); if (peer) { @@ -338,7 +339,7 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_peerconnection_processanswer_error', extraInfo: { - cameraId, + cameraId: stream, role, errorMessage: error.message, errorCode: error.code, @@ -349,24 +350,24 @@ class VideoProvider extends Component { } peer.didSDPAnswered = true; - this.processOutboundIceQueue(peer, role, cameraId); - VideoService.processInboundIceQueue(peer, cameraId); + this.processOutboundIceQueue(peer, role, stream); + VideoService.processInboundIceQueue(peer, stream); }); } else { logger.warn({ logCode: 'video_provider_startresponse_no_peer', - extraInfo: { cameraId, role }, + extraInfo: { cameraId: stream, role }, }, 'No peer on SFU camera start response handler'); } } handleIceCandidate(message) { - const { cameraId, candidate } = message; - const peer = this.webRtcPeers[cameraId]; + const { cameraId: stream, candidate } = message; + const peer = this.webRtcPeers[stream]; if (peer) { if (peer.didSDPAnswered) { - VideoService.addCandidateToPeer(peer, candidate, cameraId); + VideoService.addCandidateToPeer(peer, candidate, stream); } else { // ICE candidates are queued until a SDP answer has been processed. // This was done due to a long term iOS/Safari quirk where it'd @@ -381,89 +382,89 @@ class VideoProvider extends Component { } else { logger.warn({ logCode: 'video_provider_addicecandidate_no_peer', - extraInfo: { cameraId }, + extraInfo: { cameraId: stream }, }, 'Trailing camera ICE candidate, discarded'); } } - clearRestartTimers (cameraId) { - if (this.restartTimeout[cameraId]) { - clearTimeout(this.restartTimeout[cameraId]); - delete this.restartTimeout[cameraId]; + clearRestartTimers (stream) { + if (this.restartTimeout[stream]) { + clearTimeout(this.restartTimeout[stream]); + delete this.restartTimeout[stream]; } - if (this.restartTimer[cameraId]) { - delete this.restartTimer[cameraId]; + if (this.restartTimer[stream]) { + delete this.restartTimer[stream]; } } - stopWebRTCPeer(cameraId, restarting = false) { - const isLocal = VideoService.isLocalStream(cameraId); + stopWebRTCPeer(stream, restarting = false) { + const isLocal = VideoService.isLocalStream(stream); // in this case, 'closed' state is not caused by an error; // we stop listening to prevent this from being treated as an error - const peer = this.webRtcPeers[cameraId]; + const peer = this.webRtcPeers[stream]; if (peer && peer.peerConnection) { const conn = peer.peerConnection; conn.oniceconnectionstatechange = null; } if (isLocal) { - VideoService.stopVideo(cameraId); + VideoService.stopVideo(stream); } const role = VideoService.getRole(isLocal); logger.info({ logCode: 'video_provider_stopping_webcam_sfu', - extraInfo: { role, cameraId, restarting }, + extraInfo: { role, cameraId: stream, restarting }, }, `Camera feed stop requested. Role ${role}, restarting ${restarting}`); this.sendMessage({ id: 'stop', type: 'video', - cameraId, + cameraId: stream, role, }); // Clear the shared camera media flow timeout and current reconnect period // when destroying it if the peer won't restart if (!restarting) { - this.clearRestartTimers(cameraId); + this.clearRestartTimers(stream); } - this.destroyWebRTCPeer(cameraId); + this.destroyWebRTCPeer(stream); } - destroyWebRTCPeer(cameraId) { - const peer = this.webRtcPeers[cameraId]; - const isLocal = VideoService.isLocalStream(cameraId); + destroyWebRTCPeer(stream) { + const peer = this.webRtcPeers[stream]; + const isLocal = VideoService.isLocalStream(stream); const role = VideoService.getRole(isLocal); if (peer) { if (typeof peer.dispose === 'function') { peer.dispose(); } - delete this.outboundIceQueues[cameraId]; - delete this.webRtcPeers[cameraId]; + delete this.outboundIceQueues[stream]; + delete this.webRtcPeers[stream]; } else { logger.warn({ logCode: 'video_provider_destroywebrtcpeer_no_peer', - extraInfo: { cameraId, role }, + extraInfo: { cameraId: stream, role }, }, 'Trailing camera destroy request.'); } } - async createWebRTCPeer(cameraId, isLocal) { + async createWebRTCPeer(stream, isLocal) { let iceServers = []; const role = VideoService.getRole(isLocal); // Check if the peer is already being processed - if (this.webRtcPeers[cameraId]) { + if (this.webRtcPeers[stream]) { return; } - this.webRtcPeers[cameraId] = {}; + this.webRtcPeers[stream] = {}; try { iceServers = await fetchWebRTCMappedStunTurnServers(this.info.sessionToken); @@ -471,7 +472,7 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_fetchstunturninfo_error', extraInfo: { - cameraId, + cameraId: stream, role, errorCode: error.code, errorMessage: error.message, @@ -481,13 +482,13 @@ class VideoProvider extends Component { iceServers = getMappedFallbackStun(); } finally { const { constraints, bitrate, id: profileId } = VideoService.getCameraProfile(); - this.outboundIceQueues[cameraId] = []; + this.outboundIceQueues[stream] = []; const peerOptions = { mediaConstraints: { audio: false, video: constraints, }, - onicecandidate: this._getOnIceCandidateCallback(cameraId, isLocal), + onicecandidate: this._getOnIceCandidateCallback(stream, isLocal), }; if (iceServers.length > 0) { @@ -502,9 +503,10 @@ class VideoProvider extends Component { WebRtcPeerObj = window.kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly; } - this.webRtcPeers[cameraId] = new WebRtcPeerObj(peerOptions, (error) => { - const peer = this.webRtcPeers[cameraId]; + this.webRtcPeers[stream] = new WebRtcPeerObj(peerOptions, (error) => { + const peer = this.webRtcPeers[stream]; + peer.stream = stream; peer.started = false; peer.attached = false; peer.didSDPAnswered = false; @@ -517,18 +519,18 @@ class VideoProvider extends Component { } if (error) { - return this._onWebRTCError(error, cameraId, isLocal); + return this._onWebRTCError(error, stream, isLocal); } peer.generateOffer((errorGenOffer, offerSdp) => { if (errorGenOffer) { - return this._onWebRTCError(errorGenOffer, cameraId, isLocal); + return this._onWebRTCError(errorGenOffer, stream, isLocal); } const message = { id: 'start', type: 'video', - cameraId, + cameraId: stream, role, sdpOffer: offerSdp, meetingId: this.info.meetingId, @@ -542,32 +544,32 @@ class VideoProvider extends Component { logger.info({ logCode: 'video_provider_sfu_request_start_camera', extraInfo: { - cameraId, + cameraId: stream, cameraProfile: profileId, role, }, }, `Camera offer generated. Role: ${role}`); this.sendMessage(message); - this.setReconnectionTimeout(cameraId, isLocal, false); + this.setReconnectionTimeout(stream, isLocal, false); return false; }); return false; }); - const peer = this.webRtcPeers[cameraId]; + const peer = this.webRtcPeers[stream]; if (peer && peer.peerConnection) { const conn = peer.peerConnection; conn.onconnectionstatechange = () => { - this._handleIceConnectionStateChange(cameraId, isLocal); + this._handleIceConnectionStateChange(stream, isLocal); }; VideoService.monitor(conn); } } } - _getWebRTCStartTimeout(cameraId, isLocal) { + _getWebRTCStartTimeout(stream, isLocal) { const { intl } = this.props; return () => { @@ -578,52 +580,52 @@ class VideoProvider extends Component { // 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 oldReconnectTimer = this.restartTimer[stream]; const newReconnectTimer = Math.min( 2 * oldReconnectTimer, MAX_CAMERA_SHARE_FAILED_WAIT_TIME, ); - this.restartTimer[cameraId] = newReconnectTimer; + this.restartTimer[stream] = newReconnectTimer; // Clear the current reconnect interval so it can be re-set in createWebRTCPeer - if (this.restartTimeout[cameraId]) { - delete this.restartTimeout[cameraId]; + if (this.restartTimeout[stream]) { + delete this.restartTimeout[stream]; } logger.error({ logCode: 'video_provider_camera_view_timeout', extraInfo: { - cameraId, + cameraId: stream, role, oldReconnectTimer, newReconnectTimer, }, }, 'Camera VIEWER failed. Reconnecting.'); - this.reconnect(cameraId, isLocal); + this.reconnect(stream, isLocal); } else { // Peer that timed out is a sharer/publisher, clean it up, stop. logger.error({ logCode: 'video_provider_camera_share_timeout', extraInfo: { - cameraId, + cameraId: stream, role, }, }, 'Camera SHARER failed.'); VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout)); - this.stopWebRTCPeer(cameraId, false); + this.stopWebRTCPeer(stream, false); } }; } - _onWebRTCError(error, cameraId, isLocal) { + _onWebRTCError(error, stream, isLocal) { const { intl } = this.props; const errorMessage = intlClientErrors[error.name] || intlSFUErrors[error]; logger.error({ logCode: 'video_provider_webrtc_peer_error', extraInfo: { - cameraId, + cameraId: stream, role: VideoService.getRole(isLocal), errorName: error.name, errorMessage: error.message, @@ -633,85 +635,85 @@ class VideoProvider extends Component { // 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); + this.stopWebRTCPeer(stream, 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 peer = this.webRtcPeers[stream]; const isEstablishedConnection = peer && peer.started; - this.setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection); + this.setReconnectionTimeout(stream, 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); + this.stopWebRTCPeer(stream, true); } } - reconnect(cameraId, isLocal) { - this.stopWebRTCPeer(cameraId, true); - this.createWebRTCPeer(cameraId, isLocal); + reconnect(stream, isLocal) { + this.stopWebRTCPeer(stream, true); + this.createWebRTCPeer(stream, isLocal); } - setReconnectionTimeout(cameraId, isLocal, isEstablishedConnection) { - const peer = this.webRtcPeers[cameraId]; - const shouldSetReconnectionTimeout = !this.restartTimeout[cameraId] && !isEstablishedConnection; + setReconnectionTimeout(stream, isLocal, isEstablishedConnection) { + const peer = this.webRtcPeers[stream]; + const shouldSetReconnectionTimeout = !this.restartTimeout[stream] && !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.clearRestartTimers(stream); + return this.reconnect(stream, 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; + const newReconnectTimer = this.restartTimer[stream] || CAMERA_SHARE_FAILED_WAIT_TIME; + this.restartTimer[stream] = newReconnectTimer; - this.restartTimeout[cameraId] = setTimeout( - this._getWebRTCStartTimeout(cameraId, isLocal), - this.restartTimer[cameraId], + this.restartTimeout[stream] = setTimeout( + this._getWebRTCStartTimeout(stream, isLocal), + this.restartTimer[stream] ); } } - _getOnIceCandidateCallback(cameraId, isLocal) { + _getOnIceCandidateCallback(stream, isLocal) { return (candidate) => { - const peer = this.webRtcPeers[cameraId]; + const peer = this.webRtcPeers[stream]; const role = VideoService.getRole(isLocal); if (peer && !peer.didSDPAnswered) { - this.outboundIceQueues[cameraId].push(candidate); + this.outboundIceQueues[stream].push(candidate); return; } - this.sendIceCandidateToSFU(peer, role, candidate, cameraId); + this.sendIceCandidateToSFU(peer, role, candidate, stream); }; } - sendIceCandidateToSFU(peer, role, candidate, cameraId) { + sendIceCandidateToSFU(peer, role, candidate, stream) { const message = { type: 'video', role, id: 'onIceCandidate', candidate, - cameraId, + cameraId: stream, }; this.sendMessage(message); } - _handleIceConnectionStateChange (cameraId, isLocal) { + _handleIceConnectionStateChange (stream, isLocal) { const { intl } = this.props; - const peer = this.webRtcPeers[cameraId]; + const peer = this.webRtcPeers[stream]; const role = VideoService.getRole(isLocal); if (peer && peer.peerConnection) { const pc = peer.peerConnection; const connectionState = pc.connectionState; - notifyStreamStateChange(cameraId, connectionState); + notifyStreamStateChange(stream, connectionState); if (connectionState === 'failed' || connectionState === 'closed') { const error = new Error('iceConnectionStateError'); @@ -721,40 +723,36 @@ class VideoProvider extends Component { logger.error({ logCode: 'video_provider_ice_connection_failed_state', extraInfo: { - cameraId, + cameraId: stream, connectionState, role, }, }, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`); if (isLocal) VideoService.notify(intl.formatMessage(intlClientErrors.iceConnectionStateError)); - this._onWebRTCError( - error, - cameraId, - isLocal - ); + this._onWebRTCError(error, stream, isLocal); } } else { logger.error({ logCode: 'video_provider_ice_connection_nopeer', - extraInfo: { cameraId, role }, - }, `No peer at ICE connection state handler. Camera: ${cameraId}. Role: ${role}`); + extraInfo: { cameraId: stream, role }, + }, `No peer at ICE connection state handler. Camera: ${stream}. Role: ${role}`); } } - attachVideoStream(cameraId) { - const video = this.videoTags[cameraId]; + attachVideoStream(stream) { + const video = this.videoTags[stream]; if (video == null) { logger.warn({ logCode: 'video_provider_delay_attach_video_stream', - extraInfo: { cameraId }, + extraInfo: { cameraId: stream }, }, 'Delaying video stream attachment'); return; } - const isLocal = VideoService.isLocalStream(cameraId); - const peer = this.webRtcPeers[cameraId]; + const isLocal = VideoService.isLocalStream(stream); + const peer = this.webRtcPeers[stream]; if (peer && peer.attached && video.srcObject) { return; // Skip if the stream is already attached @@ -780,41 +778,41 @@ class VideoProvider extends Component { if (isAbleToAttach) attachVideoStreamHelper(); } - createVideoTag(cameraId, video) { - const peer = this.webRtcPeers[cameraId]; - this.videoTags[cameraId] = video; + createVideoTag(stream, video) { + const peer = this.webRtcPeers[stream]; + this.videoTags[stream] = video; if (peer && !peer.attached) { - this.attachVideoStream(cameraId); + this.attachVideoStream(stream); } } - destroyVideoTag(cameraId) { - delete this.videoTags[cameraId] + destroyVideoTag(stream) { + delete this.videoTags[stream] } handlePlayStop(message) { - const { cameraId, role } = message; + const { cameraId: stream, role } = message; logger.info({ logCode: 'video_provider_handle_play_stop', extraInfo: { - cameraId, + cameraId: stream, role, }, }, `Received request from SFU to stop camera. Role: ${role}`); - this.stopWebRTCPeer(cameraId, false); + this.stopWebRTCPeer(stream, false); } handlePlayStart(message) { - const { cameraId, role } = message; - const peer = this.webRtcPeers[cameraId]; + const { cameraId: stream, role } = message; + const peer = this.webRtcPeers[stream]; if (peer) { logger.info({ logCode: 'video_provider_handle_play_start_flowing', extraInfo: { - cameraId, + cameraId: stream, role, }, }, `Camera media is flowing (server). Role: ${role}`); @@ -822,17 +820,17 @@ class VideoProvider extends Component { peer.started = true; // Clear camera shared timeout when camera succesfully starts - this.clearRestartTimers(cameraId); + this.clearRestartTimers(stream); if (!peer.attached) { - this.attachVideoStream(cameraId); + this.attachVideoStream(stream); } - VideoService.playStart(cameraId); + VideoService.playStart(stream); } else { logger.warn({ logCode: 'video_provider_playstart_no_peer', - extraInfo: { cameraId, role }, + extraInfo: { cameraId: stream, role }, }, 'Trailing camera playStart response.'); } } @@ -840,8 +838,7 @@ class VideoProvider extends Component { handleSFUError(message) { const { intl } = this.props; const { code, reason, streamId } = message; - const cameraId = streamId; - const isLocal = VideoService.isLocalStream(cameraId); + const isLocal = VideoService.isLocalStream(streamId); const role = VideoService.getRole(isLocal); logger.error({ @@ -849,7 +846,7 @@ class VideoProvider extends Component { extraInfo: { errorCode: code, errorReason: reason, - cameraId, + cameraId: streamId, role, }, }, `SFU returned an error. Code: ${code}, reason: ${reason}`); @@ -857,10 +854,10 @@ class VideoProvider extends Component { if (isLocal) { // The publisher instance received an error from the server. There's no reconnect, // stop it. - VideoService.stopVideo(cameraId); + VideoService.stopVideo(streamId); VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200])); } else { - this.stopWebRTCPeer(cameraId, true); + this.stopWebRTCPeer(streamId, true); } } diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/service.js b/bigbluebutton-html5/imports/ui/components/video-provider/service.js index 40de0d01295a35034f0b69d2ac2b7c97624b4e84..2dc93d645a0aae8c2eaa5454cf63a2f7b122c03d 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-provider/service.js @@ -16,6 +16,10 @@ import VideoPreviewService from '../video-preview/service'; import Storage from '/imports/ui/services/storage/session'; import logger from '/imports/startup/client/logger'; import _ from 'lodash'; +import { + getSortingMethod, + sortVideoStreams, +} from '/imports/ui/components/video-provider/stream-sorting'; const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles; const MULTIPLE_CAMERAS = Meteor.settings.public.app.enableMultipleCameras; @@ -35,33 +39,15 @@ const { const PAGINATION_THRESHOLDS_CONF = Meteor.settings.public.kurento.paginationThresholds; const PAGINATION_THRESHOLDS = PAGINATION_THRESHOLDS_CONF.thresholds.sort((t1, t2) => t1.users - t2.users); const PAGINATION_THRESHOLDS_ENABLED = PAGINATION_THRESHOLDS_CONF.enabled; +const { + paginationSorting: PAGINATION_SORTING, + defaultSorting: DEFAULT_SORTING, +} = Meteor.settings.public.kurento.cameraSortingModes; const TOKEN = '_'; const ENABLE_PAGINATION_SESSION_VAR = 'enablePagination'; class VideoService { - // Paginated streams: sort with following priority: local -> presenter -> alphabetic - static sortPaginatedStreams(s1, s2) { - if (UserListService.isUserPresenter(s1.userId) && !UserListService.isUserPresenter(s2.userId)) { - return -1; - } else if (UserListService.isUserPresenter(s2.userId) && !UserListService.isUserPresenter(s1.userId)) { - return 1; - } else { - return UserListService.sortUsersByName(s1, s2); - } - } - - // Full mesh: sort with the following priority: local -> alphabetic - static sortMeshStreams(s1, s2) { - if (s1.userId === Auth.userID && s2.userId !== Auth.userID) { - return -1; - } else if (s2.userId === Auth.userID && s1.userId !== Auth.userID) { - return 1; - } else { - return UserListService.sortUsersByName(s1, s2); - } - } - constructor() { this.defineProperties({ isConnecting: false, @@ -379,50 +365,46 @@ class VideoService { // Recalculate total number of pages this.setNumberOfPages(mine.length, others.length, pageSize); const chunkIndex = this.currentVideoPageIndex * pageSize; - const paginatedStreams = others - .sort(VideoService.sortPaginatedStreams) + const paginatedStreams = sortVideoStreams(others, PAGINATION_SORTING) .slice(chunkIndex, (chunkIndex + pageSize)) || []; - const streamsOnPage = [...mine, ...paginatedStreams]; - return streamsOnPage; + if (getSortingMethod(PAGINATION_SORTING).localFirst) { + return [...mine, ...paginatedStreams]; + } + + return [...paginatedStreams, ...mine]; } getVideoStreams() { + const pageSize = this.getMyPageSize(); + const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0; + const { neededDataTypes } = isPaginationDisabled + ? getSortingMethod(DEFAULT_SORTING) + : getSortingMethod(PAGINATION_SORTING); + let streams = VideoStreams.find( { meetingId: Auth.meetingID }, - { - fields: { - userId: 1, stream: 1, name: 1, - }, - }, + { fields: neededDataTypes }, ).fetch(); const moderatorOnly = this.webcamsOnlyForModerator(); if (moderatorOnly) streams = this.filterModeratorOnly(streams); - const connectingStream = this.getConnectingStream(streams); if (connectingStream) streams.push(connectingStream); - const mappedStreams = streams.map(vs => ({ - cameraId: vs.stream, - userId: vs.userId, - name: vs.name, - })); - - const pageSize = this.getMyPageSize(); - // 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 (!this.isPaginationEnabled() || pageSize === 0) { + if (isPaginationDisabled) { return { - streams: mappedStreams.sort(VideoService.sortMeshStreams), - totalNumberOfStreams: mappedStreams.length + streams: sortVideoStreams(streams, DEFAULT_SORTING), + totalNumberOfStreams: streams.length }; } - const paginatedStreams = this.getVideoPage(mappedStreams, pageSize); - return { streams: paginatedStreams, totalNumberOfStreams: mappedStreams.length }; + const paginatedStreams = this.getVideoPage(streams, pageSize); + + return { streams: paginatedStreams, totalNumberOfStreams: streams.length }; } stopConnectingStream () { diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/stream-sorting.js b/bigbluebutton-html5/imports/ui/components/video-provider/stream-sorting.js new file mode 100644 index 0000000000000000000000000000000000000000..22f3b8ed88d9678e258bfb13786f1e4142e1d3ba --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/stream-sorting.js @@ -0,0 +1,127 @@ +import UserListService from '/imports/ui/components/user-list/service'; +import Auth from '/imports/ui/services/auth'; + +const DEFAULT_SORTING_MODE = 'LOCAL_ALPHABETICAL'; + +// lastFloorTime, descending +export const sortVoiceActivity = (s1, s2) => { + if (s2.lastFloorTime < s1.lastFloorTime) { + return -1; + } else if (s2.lastFloorTime > s1.lastFloorTime) { + return 1; + } else return 0; +}; + +// lastFloorTime (descending) -> alphabetical -> local +export const sortVoiceActivityLocal = (s1, s2) => { + if (s1.userId === Auth.userID) { + return 1; + } if (s2.userId === Auth.userID) { + return -1; + } + + return sortVoiceActivity(s1, s2) + || UserListService.sortUsersByName(s1, s2); +} + +// local -> lastFloorTime (descending) -> alphabetical +export const sortLocalVoiceActivity = (s1, s2) => { + return UserListService.sortUsersByCurrent(s1, s2) + || sortVoiceActivity(s1, s2) + || UserListService.sortUsersByName(s1, s2); +} + +// local -> alphabetic +export const sortLocalAlphabetical = (s1, s2) => { + return UserListService.sortUsersByCurrent(s1, s2) + || UserListService.sortUsersByName(s1, s2); +}; + +export const sortPresenter = (s1, s2) => { + if (UserListService.isUserPresenter(s1.userId)) { + return -1; + } else if (UserListService.isUserPresenter(s2.userId)) { + return 1; + } else return 0; +}; + +// local -> presenter -> alphabetical +export const sortLocalPresenterAlphabetical = (s1, s2) => { + return UserListService.sortUsersByCurrent(s1, s2) + || sortPresenter(s1, s2) + || UserListService.sortUsersByName(s1, s2); +}; + +// SORTING_METHODS: registrar of configurable video stream sorting modes +// Keys are the method name (String) which are to be configured in settings.yml +// ${streamSortingMethod} flag. +// +// Values are a objects which describe the sorting mode: +// - sortingMethod (function): a sorting function defined in this module +// - neededData (Object): data members that will be fetched from the server's +// video-streams collection +// - filter (Boolean): whether the sorted stream list has to be post processed +// to remove uneeded attributes. The needed attributes are: userId, streams +// and name. Anything other than that is superfluous. +// - localFirst (Boolean): true pushes local streams to the beginning of the list, +// false to the end +// The reason why this flags exists is due to pagination: local streams are +// stripped out of the streams list prior to sorting+partiotioning. They're +// added (pushed) afterwards. To avoid re-sorting the page, this flag indicates +// where it should go. +// +// To add a new sorting flavor: +// 1 - implement a sorting function, add it here (like eg sortPresenterAlphabetical) +// 1.1.: the sorting function has the same behaviour as a regular .sort callback +// 2 - add an entry to SORTING_METHODS, the key being the name to be used +// in settings.yml and the value object like the aforementioned +const SORTING_METHODS = Object.freeze({ + // Default + LOCAL_ALPHABETICAL: { + sortingMethod: sortLocalAlphabetical, + neededDataTypes: { + userId: 1, stream: 1, name: 1, + }, + localFirst: true, + }, + VOICE_ACTIVITY_LOCAL: { + sortingMethod: sortVoiceActivityLocal, + neededDataTypes: { + userId: 1, stream: 1, name: 1, lastFloorTime: 1, floor: 1, + }, + filter: true, + localFirst: false, + }, + LOCAL_VOICE_ACTIVITY: { + sortingMethod: sortLocalVoiceActivity, + neededDataTypes: { + userId: 1, stream: 1, name: 1, lastFloorTime: 1, floor: 1, + }, + filter: true, + localFirst: true, + }, + LOCAL_PRESENTER_ALPHABETICAL: { + sortingMethod: sortLocalPresenterAlphabetical, + neededDataTypes: { + userId: 1, stream: 1, name: 1, + }, + localFirst: true, + } +}); + +export const getSortingMethod = (identifier) => { + return SORTING_METHODS[identifier] || SORTING_METHODS[DEFAULT_SORTING_MODE]; +}; + +export const sortVideoStreams = (streams, mode) => { + const { sortingMethod, filter } = getSortingMethod(mode); + const sorted = streams.sort(sortingMethod); + + if (!filter) return sorted; + + return sorted.map(videoStream => ({ + stream: videoStream.stream, + userId: videoStream.userId, + name: videoStream.name, + })); +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx index 6d4a0d7f7567f608160877a42bd68798441f849f..6a476c20b9602d3c43d17f139d2c53d7e1322788 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx @@ -219,22 +219,22 @@ class VideoList extends Component { window.dispatchEvent(new Event('videoFocusChange')); } - mirrorCamera(cameraId) { + mirrorCamera(stream) { const { mirroredCameras } = this.state; - if (this.cameraIsMirrored(cameraId)) { + if (this.cameraIsMirrored(stream)) { this.setState({ - mirroredCameras: mirroredCameras.filter(x => x != cameraId), + mirroredCameras: mirroredCameras.filter(x => x != stream), }); } else { this.setState({ - mirroredCameras: mirroredCameras.concat([cameraId]), + mirroredCameras: mirroredCameras.concat([stream]), }); } } - cameraIsMirrored(cameraId) { + cameraIsMirrored(stream) { const { mirroredCameras } = this.state; - return mirroredCameras.indexOf(cameraId) >= 0; + return mirroredCameras.indexOf(stream) >= 0; } handleCanvasResize() { @@ -306,16 +306,16 @@ class VideoList extends Component { const { focusedId } = this.state; const numOfStreams = streams.length; - return streams.map((stream) => { - const { cameraId, userId, name } = stream; - const isFocused = focusedId === cameraId; + return streams.map((vs) => { + const { stream, userId, name } = vs; + const isFocused = focusedId === stream; const isFocusedIntlKey = !isFocused ? 'focus' : 'unfocus'; - const isMirrored = this.cameraIsMirrored(cameraId); + const isMirrored = this.cameraIsMirrored(stream); let actions = [{ actionName: ACTION_NAME_MIRROR, label: intl.formatMessage(intlMessages['mirrorLabel']), description: intl.formatMessage(intlMessages['mirrorDesc']), - onClick: () => this.mirrorCamera(cameraId), + onClick: () => this.mirrorCamera(stream), }]; if (numOfStreams > 2) { @@ -323,28 +323,28 @@ class VideoList extends Component { actionName: ACTION_NAME_FOCUS, label: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Label`]), description: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Desc`]), - onClick: () => this.handleVideoFocus(cameraId), + onClick: () => this.handleVideoFocus(stream), }); } return ( <div - key={cameraId} + key={stream} className={cx({ [styles.videoListItem]: true, - [styles.focused]: focusedId === cameraId && numOfStreams > 2, + [styles.focused]: focusedId === stream && numOfStreams > 2, })} > <VideoListItemContainer numOfStreams={numOfStreams} - cameraId={cameraId} + cameraId={stream} userId={userId} name={name} mirrored={isMirrored} actions={actions} onVideoItemMount={(videoRef) => { this.handleCanvasResize(); - onVideoItemMount(cameraId, videoRef); + onVideoItemMount(stream, videoRef); }} onVideoItemUnmount={onVideoItemUnmount} swapLayout={swapLayout} diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/container.jsx index 0b91470510fd44cec7c9e49443251a70f7aa52b6..bdca16332099e5ffc0160d94ea37b76af0162f35 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/container.jsx @@ -13,6 +13,7 @@ export default withTracker((props) => { } = props; return { - voiceUser: VoiceUsers.findOne({ intId: userId }), + voiceUser: VoiceUsers.findOne({ intId: userId }, + { fields: { 'muted': 1, 'listenOnly': 1, 'talking': 1 } }), }; })(VideoListItemContainer); diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index aa09382a639d7547ff2daab2b6ce6f028dcc9cb8..e174c88df77a711cb1990f0d3f8d1b741838ca93 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -269,6 +269,14 @@ public: autoShareWebcam: false skipVideoPreview: false skipVideoPreviewOnFirstJoin: false + # cameraSortingModes.paginationSorting: sorting mode to be applied when pagination is active + # cameraSortingModes.defaultSorting: sorting mode when pagination is not active (full mesh) + # Current implemented modes are: + # 'LOCAL_ALPHABETICAL' | 'VOICE_ACTIVITY_LOCAL' | 'LOCAL_VOICE_ACTIVITY' | 'LOCAL_PRESENTER_ALPHABETICAL' + # The algorithm names are self-explanatory. + cameraSortingModes: + defaultSorting: LOCAL_ALPHABETICAL + paginationSorting: LOCAL_PRESENTER_ALPHABETICAL # Entry `thresholds` is an array of: # - threshold: minimum number of cameras being shared for profile to applied # profile: a camera profile id from the cameraProfiles configuration array