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