From 1661c5518bbe6e24ecb3102f75a84b9f3d9030cf Mon Sep 17 00:00:00 2001
From: Robert Long <robert@robertlong.me>
Date: Thu, 26 Aug 2021 11:03:48 -0700
Subject: [PATCH] Add ability to toggle presenter by clicking name

---
 src/ConferenceCallManagerHooks.js |  15 ++
 src/GridDemo.jsx                  |  15 +-
 src/Room.jsx                      |   6 +-
 src/VideoGrid.jsx                 | 387 ++++++++++++++++++++++++------
 src/VideoGrid.module.css          |   1 +
 5 files changed, 354 insertions(+), 70 deletions(-)

diff --git a/src/ConferenceCallManagerHooks.js b/src/ConferenceCallManagerHooks.js
index 09941b9..145fec7 100644
--- a/src/ConferenceCallManagerHooks.js
+++ b/src/ConferenceCallManagerHooks.js
@@ -339,6 +339,20 @@ export function useVideoRoom(manager, roomId, timeout = 5000) {
     setState((prevState) => ({ ...prevState, videoMuted: manager.videoMuted }));
   }, [manager]);
 
+  const togglePresenter = useCallback((selectedParticipant) => {
+    setState((prevState) => ({
+      ...prevState,
+      participants: prevState.participants.map((participant) =>
+        participant === selectedParticipant
+          ? {
+              ...participant,
+              presenter: !participant.presenter,
+            }
+          : participant
+      ),
+    }));
+  }, []);
+
   return {
     loading,
     joined,
@@ -350,6 +364,7 @@ export function useVideoRoom(manager, roomId, timeout = 5000) {
     leaveCall,
     toggleMuteVideo,
     toggleMuteAudio,
+    togglePresenter,
     videoMuted,
     audioMuted,
   };
diff --git a/src/GridDemo.jsx b/src/GridDemo.jsx
index 15cc863..479849e 100644
--- a/src/GridDemo.jsx
+++ b/src/GridDemo.jsx
@@ -20,6 +20,19 @@ export function GridDemo() {
     ]);
   }, [stream]);
 
+  const togglePresenter = useCallback((selectedParticipant) => {
+    setParticipants((participants) =>
+      participants.map((participant) =>
+        participant === selectedParticipant
+          ? {
+              ...participant,
+              presenter: !participant.presenter,
+            }
+          : participant
+      )
+    );
+  }, []);
+
   const removeParticipant = useCallback((key) => {
     setParticipants((participants) =>
       participants.filter((participant) => participant.userId !== key)
@@ -43,7 +56,7 @@ export function GridDemo() {
           </button>
         )}
       </div>
-      <VideoGrid participants={participants} />
+      <VideoGrid participants={participants} onClickNameTag={togglePresenter} />
     </div>
   );
 }
diff --git a/src/Room.jsx b/src/Room.jsx
index 4ff6e34..9bd091d 100644
--- a/src/Room.jsx
+++ b/src/Room.jsx
@@ -48,6 +48,7 @@ export function Room({ manager }) {
     leaveCall,
     toggleMuteVideo,
     toggleMuteAudio,
+    togglePresenter,
     videoMuted,
     audioMuted,
   } = useVideoRoom(manager, roomId);
@@ -111,7 +112,10 @@ export function Room({ manager }) {
         </div>
       )}
       {!loading && room && joined && participants.length > 0 && (
-        <VideoGrid participants={participants} />
+        <VideoGrid
+          participants={participants}
+          onClickNameTag={togglePresenter}
+        />
       )}
       {!loading && room && joined && (
         <div className={styles.footer}>
diff --git a/src/VideoGrid.jsx b/src/VideoGrid.jsx
index 4f2737a..8891929 100644
--- a/src/VideoGrid.jsx
+++ b/src/VideoGrid.jsx
@@ -36,18 +36,209 @@ function isInside([x, y], targetTile) {
   return true;
 }
 
-function getTilePositions(tileCount, gridBounds) {
-  const newTilePositions = [];
-  const { width: gridWidth, height: gridHeight } = gridBounds;
-  const gap = 8;
+function getTilePositions(tileCount, gridBounds, presenterTileCount) {
+  if (tileCount === 0) {
+    return [];
+  }
 
   if (tileCount > 12) {
     console.warn("Over 12 tiles is not currently supported");
   }
 
-  if (tileCount > 0) {
-    const gridAspectRatio = gridWidth / gridHeight;
+  if (presenterTileCount > 3) {
+    console.warn("Over 3 presenters is not currently supported");
+  }
 
+  const gridWidth = gridBounds.width;
+  const gridHeight = gridBounds.height;
+  const gridAspectRatio = gridWidth / gridHeight;
+
+  if (presenterTileCount) {
+    const subGridTileCount = tileCount - presenterTileCount;
+
+    let presenterGridWidth,
+      presenterGridHeight,
+      presenterColumnCount,
+      presenterRowCount,
+      presenterTileAspectRatio;
+
+    let subGridWidth,
+      subGridHeight,
+      subGridOffsetLeft,
+      subGridOffsetTop,
+      subGridColumnCount,
+      subGridRowCount,
+      subGridTileAspectRatio;
+
+    if (gridAspectRatio < 3 / 4) {
+      // Phone
+      presenterGridWidth = gridWidth;
+      presenterColumnCount = 1;
+      presenterRowCount = presenterTileCount;
+      presenterTileAspectRatio = 16 / 9;
+      subGridTileAspectRatio = 16 / 9;
+
+      if (presenterTileCount > 2) {
+        presenterColumnCount = 2;
+        presenterRowCount = 2;
+        presenterTileAspectRatio = 0;
+      }
+
+      if (subGridTileCount < 3) {
+        if (presenterTileCount === 1) {
+        }
+        subGridColumnCount = presenterTileCount === 1 ? 1 : subGridTileCount;
+        subGridRowCount = presenterTileCount === 1 ? subGridTileCount : 1;
+        subGridTileAspectRatio = presenterTileCount === 1 ? 16 / 9 : 0;
+      } else if (subGridTileCount < 5) {
+        subGridColumnCount = 2;
+        subGridRowCount = 2;
+      } else if (subGridTileCount < 7) {
+        subGridColumnCount = 2;
+        subGridRowCount = 3;
+      } else if (subGridTileCount < 10) {
+        subGridColumnCount = 3;
+        subGridRowCount = 3;
+      } else {
+        subGridColumnCount = 4;
+        subGridRowCount = 3;
+      }
+
+      presenterGridHeight = Math.round(
+        gridHeight *
+          (1 -
+            1 /
+              Math.max(
+                presenterRowCount + 2 - Math.max(subGridRowCount - 1, 0),
+                2
+              ))
+      );
+
+      subGridWidth = gridWidth;
+      subGridHeight = gridHeight - presenterGridHeight;
+      subGridOffsetTop = presenterGridHeight;
+      subGridOffsetLeft = 0;
+    } else if (gridAspectRatio < 1) {
+      // Tablet
+      presenterGridWidth = gridWidth;
+      presenterColumnCount = 1;
+      presenterRowCount = presenterTileCount;
+      presenterTileAspectRatio = 16 / 9;
+      subGridTileAspectRatio = 16 / 9;
+
+      if (presenterTileCount > 2) {
+        presenterColumnCount = 2;
+        presenterRowCount = 2;
+        presenterTileAspectRatio = 0;
+      }
+
+      if (subGridTileCount < 3) {
+        if (presenterTileCount === 1) {
+        }
+        subGridColumnCount = presenterTileCount === 1 ? 1 : subGridTileCount;
+        subGridRowCount = presenterTileCount === 1 ? subGridTileCount : 1;
+        subGridTileAspectRatio = presenterTileCount === 1 ? 16 / 9 : 0;
+      } else if (subGridTileCount < 5) {
+        subGridColumnCount = 2;
+        subGridRowCount = 2;
+      } else if (subGridTileCount < 7) {
+        subGridColumnCount = 2;
+        subGridRowCount = 3;
+      } else if (subGridTileCount < 10) {
+        subGridColumnCount = 3;
+        subGridRowCount = 3;
+      } else {
+        subGridColumnCount = 4;
+        subGridRowCount = 3;
+      }
+
+      presenterGridHeight = Math.round(
+        gridHeight *
+          (1 -
+            1 /
+              Math.max(
+                presenterRowCount + 2 - Math.max(subGridRowCount - 1, 0),
+                2
+              ))
+      );
+
+      subGridWidth = gridWidth;
+      subGridHeight = gridHeight - presenterGridHeight;
+      subGridOffsetTop = presenterGridHeight;
+      subGridOffsetLeft = 0;
+    } else if (gridAspectRatio < 17 / 9) {
+      // Computer
+      presenterGridWidth = gridWidth * (2 / 3);
+      presenterGridHeight = gridHeight;
+      presenterColumnCount = 1;
+      presenterRowCount = presenterTileCount;
+      presenterTileAspectRatio = 0;
+
+      subGridWidth = gridWidth - presenterGridWidth;
+      subGridHeight = gridHeight;
+      subGridColumnCount = Math.ceil(subGridTileCount / 6);
+      subGridRowCount = Math.ceil(subGridTileCount / subGridColumnCount);
+      subGridOffsetTop = 0;
+      subGridOffsetLeft = presenterGridWidth;
+      subGridTileAspectRatio = 16 / 9;
+    } else if (gridAspectRatio <= 32 / 9) {
+      // Ultrawide
+      presenterGridWidth = gridWidth * (2 / 3);
+      presenterGridHeight = gridHeight;
+      presenterColumnCount = 1;
+      presenterRowCount = presenterTileCount;
+      presenterTileAspectRatio = 16 / 9;
+
+      subGridWidth = gridWidth - presenterGridWidth;
+      subGridHeight = gridHeight;
+      subGridColumnCount = Math.ceil(subGridTileCount / 4);
+      subGridRowCount = Math.ceil(subGridTileCount / subGridColumnCount);
+      subGridOffsetTop = 0;
+      subGridOffsetLeft = presenterGridWidth;
+      subGridTileAspectRatio = 16 / 9;
+    } else {
+      // Super Ultrawide
+      presenterGridWidth = gridWidth * (2 / 3);
+      presenterGridHeight = gridHeight;
+      presenterColumnCount = 1;
+      presenterRowCount = presenterTileCount;
+      presenterTileAspectRatio = 16 / 9;
+
+      subGridWidth = gridWidth - presenterGridWidth;
+      subGridHeight = gridHeight;
+      subGridColumnCount = Math.ceil(subGridTileCount / 3);
+      subGridRowCount = Math.ceil(subGridTileCount / subGridColumnCount);
+      subGridOffsetTop = 0;
+      subGridOffsetLeft = presenterGridWidth;
+      subGridTileAspectRatio = 16 / 9;
+    }
+
+    const presenterPositions = getSubGridPositions(
+      presenterTileCount,
+      presenterColumnCount,
+      presenterRowCount,
+      presenterTileAspectRatio,
+      {
+        width: presenterGridWidth,
+        height: presenterGridHeight,
+      }
+    );
+
+    const subGridPositions = getSubGridPositions(
+      subGridTileCount,
+      subGridColumnCount,
+      subGridRowCount,
+      subGridTileAspectRatio,
+      {
+        width: subGridWidth,
+        height: subGridHeight,
+        offsetTop: subGridOffsetTop,
+        offsetLeft: subGridOffsetLeft,
+      }
+    );
+
+    return [...presenterPositions, ...subGridPositions];
+  } else {
     let columnCount, rowCount;
     let tileAspectRatio = 16 / 9;
 
@@ -151,81 +342,111 @@ function getTilePositions(tileCount, gridBounds) {
       }
     }
 
-    const boxWidth = Math.round(
-      (gridWidth - gap * (columnCount + 1)) / columnCount
-    );
-    const boxHeight = Math.round(
-      (gridHeight - gap * (rowCount + 1)) / rowCount
+    return getSubGridPositions(
+      tileCount,
+      columnCount,
+      rowCount,
+      tileAspectRatio,
+      gridBounds
     );
+  }
+}
 
-    let tileWidth, tileHeight;
+function getSubGridPositions(
+  tileCount,
+  columnCount,
+  rowCount,
+  tileAspectRatio,
+  gridBounds
+) {
+  if (tileCount === 0) {
+    return [];
+  }
 
-    if (tileAspectRatio) {
-      const boxAspectRatio = boxWidth / boxHeight;
+  const newTilePositions = [];
+  const gridWidth = gridBounds.width;
+  const gridHeight = gridBounds.height;
+  const gridOffsetLeft = gridBounds.offsetLeft || 0;
+  const gridOffsetTop = gridBounds.offsetTop || 0;
+  const gap = 8;
 
-      if (boxAspectRatio > tileAspectRatio) {
-        tileWidth = boxHeight * tileAspectRatio;
-        tileHeight = boxHeight;
-      } else {
-        tileWidth = boxWidth;
-        tileHeight = boxWidth / tileAspectRatio;
-      }
+  const boxWidth = Math.round(
+    (gridWidth - gap * (columnCount + 1)) / columnCount
+  );
+  const boxHeight = Math.round((gridHeight - gap * (rowCount + 1)) / rowCount);
+
+  let tileWidth, tileHeight;
+
+  if (tileAspectRatio) {
+    const boxAspectRatio = boxWidth / boxHeight;
+
+    if (boxAspectRatio > tileAspectRatio) {
+      tileWidth = boxHeight * tileAspectRatio;
+      tileHeight = boxHeight;
     } else {
       tileWidth = boxWidth;
-      tileHeight = boxHeight;
+      tileHeight = boxWidth / tileAspectRatio;
+    }
+  } else {
+    tileWidth = boxWidth;
+    tileHeight = boxHeight;
+  }
+
+  const paddingTop =
+    (gridHeight - tileHeight * rowCount - gap * (rowCount - 1)) / 2;
+
+  const paddingLeft =
+    (gridWidth - tileWidth * columnCount - gap * (columnCount - 1)) / 2;
+
+  for (let i = 0; i < tileCount; i++) {
+    const verticalIndex = Math.floor(i / columnCount);
+    const top =
+      gridOffsetTop +
+      verticalIndex * tileHeight +
+      verticalIndex * gap +
+      paddingTop;
+
+    let rowItemCount;
+
+    if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) {
+      rowItemCount = tileCount % columnCount;
+    } else {
+      rowItemCount = columnCount;
     }
 
-    const paddingTop =
-      (gridHeight - tileHeight * rowCount - gap * (rowCount - 1)) / 2;
+    const horizontalIndex = i % columnCount;
 
-    const paddingLeft =
-      (gridWidth - tileWidth * columnCount - gap * (columnCount - 1)) / 2;
+    let centeringPadding = 0;
 
-    for (let i = 0; i < tileCount; i++) {
-      const verticalIndex = Math.floor(i / columnCount);
-      const top = verticalIndex * tileHeight + verticalIndex * gap + paddingTop;
-
-      let rowItemCount;
-
-      if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) {
-        rowItemCount = tileCount % columnCount;
-      } else {
-        rowItemCount = columnCount;
-      }
-
-      const horizontalIndex = i % columnCount;
-
-      let centeringPadding = 0;
-
-      if (rowItemCount < columnCount) {
-        centeringPadding = Math.round(
-          (gridWidth -
-            (tileWidth * rowItemCount +
-              (gap * rowItemCount - 1) +
-              paddingLeft * 2)) /
-            2
-        );
-      }
-
-      const left =
-        paddingLeft +
-        centeringPadding +
-        gap * horizontalIndex +
-        tileWidth * horizontalIndex;
-
-      newTilePositions.push({
-        width: tileWidth,
-        height: tileHeight,
-        x: left,
-        y: top,
-      });
+    if (rowItemCount < columnCount) {
+      centeringPadding = Math.round(
+        (gridWidth -
+          (tileWidth * rowItemCount +
+            (gap * rowItemCount - 1) +
+            paddingLeft * 2)) /
+          2
+      );
     }
+
+    const left =
+      gridOffsetLeft +
+      paddingLeft +
+      centeringPadding +
+      gap * horizontalIndex +
+      tileWidth * horizontalIndex;
+
+    newTilePositions.push({
+      width: tileWidth,
+      height: tileHeight,
+      x: left,
+      y: top,
+    });
   }
 
   return newTilePositions;
 }
 
-export function VideoGrid({ participants }) {
+export function VideoGrid({ participants, onClickNameTag }) {
   const [{ tiles, tilePositions }, setTileState] = useState({
     tiles: [],
     tilePositions: [],
@@ -239,6 +460,7 @@ export function VideoGrid({ participants }) {
     setTileState(({ tiles }) => {
       const newTiles = [];
       const removedTileKeys = [];
+      let presenterTileCount = 0;
 
       for (const tile of tiles) {
         const participant = participants.find(
@@ -264,6 +486,10 @@ export function VideoGrid({ participants }) {
       }
 
       for (const participant of participants) {
+        if (participant.presenter) {
+          presenterTileCount++;
+        }
+
         if (newTiles.some(({ key }) => participant.userId === key)) {
           continue;
         }
@@ -276,6 +502,11 @@ export function VideoGrid({ participants }) {
         });
       }
 
+      newTiles.sort(
+        (a, b) =>
+          (b.participant.presenter ? 1 : 0) - (a.participant.presenter ? 1 : 0)
+      );
+
       if (removedTileKeys.length > 0) {
         setTimeout(() => {
           if (!isMounted.current) {
@@ -289,7 +520,11 @@ export function VideoGrid({ participants }) {
 
             return {
               tiles: newTiles,
-              tilePositions: getTilePositions(newTiles.length, gridBounds),
+              tilePositions: getTilePositions(
+                newTiles.length,
+                gridBounds,
+                presenterTileCount
+              ),
             };
           });
         }, 250);
@@ -297,7 +532,11 @@ export function VideoGrid({ participants }) {
 
       return {
         tiles: newTiles,
-        tilePositions: getTilePositions(newTiles.length, gridBounds),
+        tilePositions: getTilePositions(
+          newTiles.length,
+          gridBounds,
+          presenterTileCount
+        ),
       };
     });
   }, [participants, gridBounds]);
@@ -413,6 +652,7 @@ export function VideoGrid({ participants }) {
               ...style,
             }}
             {...tile}
+            onClickNameTag={onClickNameTag}
           />
         );
       })}
@@ -420,7 +660,13 @@ export function VideoGrid({ participants }) {
   );
 }
 
-function ParticipantTile({ style, participant, remove, ...rest }) {
+function ParticipantTile({
+  style,
+  participant,
+  remove,
+  onClickNameTag,
+  ...rest
+}) {
   const videoRef = useRef();
 
   useEffect(() => {
@@ -445,6 +691,11 @@ function ParticipantTile({ style, participant, remove, ...rest }) {
         className={classNames(styles.participantName, {
           [styles.speaking]: participant.speaking,
         })}
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          onClickNameTag(participant);
+        }}
       >
         {participant.speaking ? (
           <MicIcon />
diff --git a/src/VideoGrid.module.css b/src/VideoGrid.module.css
index df8f560..69cf53d 100644
--- a/src/VideoGrid.module.css
+++ b/src/VideoGrid.module.css
@@ -46,6 +46,7 @@ limitations under the License.
   justify-content: center;
   border-radius: 8px;
   user-select: none;
+  cursor: pointer;
 }
 
 .participantName > * {