diff --git a/custom/fitness-demo/components/Call/Room.js b/custom/fitness-demo/components/Call/Room.js
index 0f5f023..4dbdf79 100644
--- a/custom/fitness-demo/components/Call/Room.js
+++ b/custom/fitness-demo/components/Call/Room.js
@@ -3,13 +3,13 @@ import VideoContainer from '@custom/shared/components/VideoContainer/VideoContai
import { Container } from './Container';
import { Header } from './Header';
-import { VideoGrid } from './VideoGrid';
+import { VideoView } from './VideoView';
export function Room({ children }) {
return (
- {children ? children : }
+ {children ? children : }
);
}
diff --git a/custom/fitness-demo/components/Call/VideoGrid.js b/custom/fitness-demo/components/Call/VideoGrid.js
deleted file mode 100644
index 1e3f60c..0000000
--- a/custom/fitness-demo/components/Call/VideoGrid.js
+++ /dev/null
@@ -1,191 +0,0 @@
-import React, { useState, useMemo, useEffect, useRef } from 'react';
-import ParticipantBar from '@custom/shared/components/ParticipantBar';
-import Tile from '@custom/shared/components/Tile';
-import { DEFAULT_ASPECT_RATIO } from '@custom/shared/constants';
-import { useCallState } from '@custom/shared/contexts/CallProvider';
-import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
-import { useDeepCompareMemo } from 'use-deep-compare';
-
-const SIDEBAR_WIDTH = 186;
-
-/**
- * Basic unpaginated video tile grid, scaled by aspect ratio
- *
- * Note: this component is designed to work with automated track subscriptions
- * and is only suitable for small call sizes as it will show all participants
- * and not paginate.
- *
- * Note: this grid does not show screenshares (just participant cams)
- *
- * Note: this grid does not sort participants
- */
-export const VideoGrid = React.memo(
- () => {
- const containerRef = useRef();
- const { allParticipants, participants, screens, localParticipant } = useParticipants();
- const { showLocalVideo } = useCallState();
- const [dimensions, setDimensions] = useState({
- width: 1,
- height: 1,
- });
-
- // Keep a reference to the width and height of the page, so we can repack
- useEffect(() => {
- let frame;
- const handleResize = () => {
- if (frame) cancelAnimationFrame(frame);
- frame = requestAnimationFrame(() =>
- setDimensions({
- width: containerRef.current?.clientWidth,
- height: containerRef.current?.clientHeight,
- })
- );
- };
- handleResize();
- window.addEventListener('resize', handleResize);
- window.addEventListener('orientationchange', handleResize);
- return () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('orientationchange', handleResize);
- };
- }, []);
-
- const hasScreenshares = useMemo(() => screens.length > 0, [screens]);
-
- // Basic brute-force packing algo
- const layout = useMemo(() => {
- const aspectRatio = DEFAULT_ASPECT_RATIO;
- const tileCount = hasScreenshares ? screens.length : participants.length || 0;
- const w = dimensions.width;
- const h = dimensions.height;
-
- // brute-force search layout where video occupy the largest area of the container
- let bestLayout = {
- area: 0,
- cols: 0,
- rows: 0,
- width: 0,
- height: 0,
- };
-
- for (let cols = 0; cols <= tileCount; cols += 1) {
- const rows = Math.ceil(tileCount / cols);
- const hScale = w / (cols * aspectRatio);
- const vScale = h / rows;
- let width;
- let height;
- if (hScale <= vScale) {
- width = Math.floor(w / cols);
- height = Math.floor(width / aspectRatio);
- } else {
- height = Math.floor(h / rows);
- width = Math.floor(height * aspectRatio);
- }
- const area = width * height;
- if (area > bestLayout.area) {
- bestLayout = {
- area,
- width,
- height,
- rows,
- cols,
- };
- }
- }
-
- return bestLayout;
- }, [hasScreenshares, screens.length, participants.length, dimensions.width, dimensions.height]);
-
- const otherParticipants = useMemo(
- () => participants.filter(({ isLocal }) => !isLocal),
- [participants]
- );
-
- const fixedItems = useMemo(() => {
- const items = [];
- if (showLocalVideo) {
- items.push(localParticipant);
- }
- if (hasScreenshares && otherParticipants.length > 0) {
- items.push(otherParticipants[0]);
- }
- return items;
- }, [hasScreenshares, localParticipant, otherParticipants, showLocalVideo]);
-
- const otherItems = useMemo(() => {
- if (otherParticipants.length > 1) {
- return otherParticipants.slice(hasScreenshares ? 1 : 0);
- }
- return [];
- }, [hasScreenshares, otherParticipants]);
-
- // Memoize our tile list to avoid unnecassary re-renders
- const tiles = useDeepCompareMemo(
- () =>
- participants.map((p) => (
-
- )),
- [layout, participants]
- );
-
- const screenShareTiles = useDeepCompareMemo(
- () =>
- screens.map((p) => (
-
- )),
- [layout, screens]
- );
-
- if (!participants.length) return null;
-
- return (
-
-
- {screenShareTiles}
- {!hasScreenshares && tiles}
-
- {hasScreenshares && (
-
- )}
-
-
- );
- },
- () => true
-);
-
-export default VideoGrid;
diff --git a/custom/fitness-demo/components/Call/VideoView.js b/custom/fitness-demo/components/Call/VideoView.js
new file mode 100644
index 0000000..93e3118
--- /dev/null
+++ b/custom/fitness-demo/components/Call/VideoView.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState, VIEW_MODE_SPEAKER } from '@custom/shared/contexts/UIStateProvider';
+import { GridView } from '../GridView/GridView';
+import { SpeakerView } from '../SpeakerView';
+
+export const VideoView = () => {
+ const { viewMode } = useUIState();
+ const { participants } = useParticipants();
+
+ if (!participants.length) return null;
+ return viewMode === VIEW_MODE_SPEAKER ? : ;
+};
+
+export default VideoView;
diff --git a/custom/fitness-demo/components/GridView/GridView.js b/custom/fitness-demo/components/GridView/GridView.js
new file mode 100644
index 0000000..4a3fe25
--- /dev/null
+++ b/custom/fitness-demo/components/GridView/GridView.js
@@ -0,0 +1,367 @@
+import React, {
+ useRef,
+ useCallback,
+ useMemo,
+ useEffect,
+ useState,
+} from 'react';
+import Button from '@custom/shared/components/Button';
+import Tile from '@custom/shared/components/Tile';
+import {
+ DEFAULT_ASPECT_RATIO,
+ MEETING_STATE_JOINED,
+} from '@custom/shared/constants';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { isLocalId } from '@custom/shared/contexts/participantsState';
+import { useActiveSpeaker } from '@custom/shared/hooks/useActiveSpeaker';
+import { useCamSubscriptions } from '@custom/shared/hooks/useCamSubscriptions';
+import { ReactComponent as IconArrow } from '@custom/shared/icons/raquo-md.svg';
+import sortByKey from '@custom/shared/lib/sortByKey';
+import PropTypes from 'prop-types';
+import { useDeepCompareMemo } from 'use-deep-compare';
+
+// --- Constants
+
+const MIN_TILE_WIDTH = 280;
+const MAX_TILES_PER_PAGE = 12;
+
+export const GridView = ({
+ maxTilesPerPage = MAX_TILES_PER_PAGE,
+}) => {
+ const { callObject } = useCallState();
+ const {
+ activeParticipant,
+ participantCount,
+ participants,
+ swapParticipantPosition,
+ } = useParticipants();
+ const activeSpeakerId = useActiveSpeaker();
+
+ // Memoized participant count (does not include screen shares)
+ const displayableParticipantCount = useMemo(
+ () => participantCount,
+ [participantCount]
+ );
+
+ // Grid size (dictated by screen size)
+ const [dimensions, setDimensions] = useState({
+ width: 1,
+ height: 1,
+ });
+ const [page, setPage] = useState(1);
+ const [pages, setPages] = useState(1);
+
+ const gridRef = useRef(null);
+
+ // -- Layout / UI
+
+ // Update width and height of grid when window is resized
+ useEffect(() => {
+ let frame;
+ const handleResize = () => {
+ if (frame) cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ const width = gridRef.current?.clientWidth;
+ const height = gridRef.current?.clientHeight;
+ setDimensions({ width, height });
+ });
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('orientationchange', handleResize);
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('orientationchange', handleResize);
+ };
+ }, []);
+
+ // Memoized reference to the max columns and rows possible given screen size
+ const [maxColumns, maxRows] = useMemo(() => {
+ const { width, height } = dimensions;
+ const columns = Math.max(1, Math.floor(width / MIN_TILE_WIDTH));
+ const widthPerTile = width / columns;
+ const rows = Math.max(1, Math.floor(height / (widthPerTile * (9 / 16))));
+ return [columns, rows];
+ }, [dimensions]);
+
+ // Memoized count of how many tiles can we show per page
+ const pageSize = useMemo(
+ () => Math.min(maxColumns * maxRows, maxTilesPerPage),
+ [maxColumns, maxRows, maxTilesPerPage]
+ );
+
+ // Calc and set the total number of pages as participant count mutates
+ useEffect(() => {
+ setPages(Math.ceil(displayableParticipantCount / pageSize));
+ }, [pageSize, displayableParticipantCount]);
+
+ // Make sure we never see a blank page (if we're on the last page and people leave)
+ useEffect(() => {
+ if (page <= pages) return;
+ setPage(pages);
+ }, [page, pages]);
+
+ // Brutishly calculate the dimensions of each tile given the size of the grid
+ const [tileWidth, tileHeight] = useMemo(() => {
+ const { width, height } = dimensions;
+ const n = Math.min(pageSize, displayableParticipantCount);
+ if (n === 0) return [width, height];
+ const dims = [];
+ for (let i = 1; i <= n; i += 1) {
+ let maxWidthPerTile = (width - (i - 1)) / i;
+ let maxHeightPerTile = maxWidthPerTile / DEFAULT_ASPECT_RATIO;
+ const rows = Math.ceil(n / i);
+ if (rows * maxHeightPerTile > height) {
+ maxHeightPerTile = (height - (rows - 1)) / rows;
+ maxWidthPerTile = maxHeightPerTile * DEFAULT_ASPECT_RATIO;
+ dims.push([maxWidthPerTile, maxHeightPerTile]);
+ } else {
+ dims.push([maxWidthPerTile, maxHeightPerTile]);
+ }
+ }
+ return dims.reduce(
+ ([rw, rh], [w, h]) => {
+ if (w * h < rw * rh) return [rw, rh];
+ return [w, h];
+ },
+ [0, 0]
+ );
+ }, [dimensions, pageSize, displayableParticipantCount]);
+
+ // -- Track subscriptions
+
+ // Memoized array of participants on the current page (those we can see)
+ const visibleParticipants = useMemo(
+ () =>
+ participants.length - page * pageSize > 0
+ ? participants.slice((page - 1) * pageSize, page * pageSize)
+ : participants.slice(-pageSize),
+ [page, pageSize, participants]
+ );
+
+ /**
+ * Play / pause tracks based on pagination
+ * Note: we pause adjacent page tracks and unsubscribe from everything else
+ */
+ const camSubscriptions = useMemo(() => {
+ const maxSubs = 3 * pageSize;
+
+ // Determine participant ids to subscribe to or stage, based on page
+ let renderedOrBufferedIds = [];
+ switch (page) {
+ // First page
+ case 1:
+ renderedOrBufferedIds = participants
+ .slice(0, Math.min(maxSubs, 2 * pageSize))
+ .map((p) => p.id);
+ break;
+ // Last page
+ case Math.ceil(participants.length / pageSize):
+ renderedOrBufferedIds = participants
+ .slice(-Math.min(maxSubs, 2 * pageSize))
+ .map((p) => p.id);
+ break;
+ // Any other page
+ default:
+ {
+ const buffer = (maxSubs - pageSize) / 2;
+ const min = (page - 1) * pageSize - buffer;
+ const max = page * pageSize + buffer;
+ renderedOrBufferedIds = participants.slice(min, max).map((p) => p.id);
+ }
+ break;
+ }
+
+ const subscribedIds = [];
+ const stagedIds = [];
+
+ // Decide whether to subscribe to or stage participants'
+ // track based on visibility
+ renderedOrBufferedIds.forEach((id) => {
+ if (id !== isLocalId()) {
+ if (visibleParticipants.some((vp) => vp.id === id)) {
+ subscribedIds.push(id);
+ } else {
+ stagedIds.push(id);
+ }
+ }
+ });
+
+ return {
+ subscribedIds,
+ stagedIds,
+ };
+ }, [page, pageSize, participants, visibleParticipants]);
+
+ useCamSubscriptions(
+ camSubscriptions?.subscribedIds,
+ camSubscriptions?.pausedIds
+ );
+
+ /**
+ * Set bandwidth layer based on amount of visible participants
+ */
+ useEffect(() => {
+ if (!(callObject && callObject.meetingState() === MEETING_STATE_JOINED))
+ return;
+ const count = visibleParticipants.length;
+
+ let layer;
+ if (count < 5) {
+ // highest quality layer
+ layer = 2;
+ } else if (count < 10) {
+ // mid quality layer
+ layer = 1;
+ } else {
+ // low qualtiy layer
+ layer = 0;
+ }
+
+ const receiveSettings = visibleParticipants.reduce(
+ (settings, participant) => {
+ if (isLocalId(participant.id)) return settings;
+ return { ...settings, [participant.id]: { video: { layer } } };
+ },
+ {}
+ );
+ callObject.updateReceiveSettings(receiveSettings);
+ }, [visibleParticipants, callObject]);
+
+ // -- Active speaker
+
+ /**
+ * Handle position updates based on active speaker events
+ */
+ const handleActiveSpeakerChange = useCallback(
+ (peerId) => {
+ if (!peerId) return;
+ // active participant is already visible
+ if (visibleParticipants.some(({ id }) => id === peerId)) return;
+ // ignore repositioning when viewing page > 1
+ if (page > 1) return;
+
+ /**
+ * We can now assume that
+ * a) the user is looking at page 1
+ * b) the most recent active participant is not visible on page 1
+ * c) we'll have to promote the most recent participant's position to page 1
+ *
+ * To achieve that, we'll have to
+ * - find the least recent active participant on page 1
+ * - swap least & most recent active participant's position via setParticipantPosition
+ */
+ const sortedVisibleRemoteParticipants = visibleParticipants
+ .filter(({ isLocal }) => !isLocal)
+ .sort((a, b) => sortByKey(a, b, 'lastActiveDate'));
+
+ if (!sortedVisibleRemoteParticipants.length) return;
+
+ swapParticipantPosition(sortedVisibleRemoteParticipants[0].id, peerId);
+ },
+ [page, swapParticipantPosition, visibleParticipants]
+ );
+
+ useEffect(() => {
+ if (page > 1 || !activeSpeakerId) return;
+ handleActiveSpeakerChange(activeSpeakerId);
+ }, [activeSpeakerId, handleActiveSpeakerChange, page]);
+
+ const tiles = useDeepCompareMemo(
+ () =>
+ visibleParticipants.map((p) => (
+
+ )),
+ [
+ activeParticipant,
+ participantCount,
+ tileWidth,
+ tileHeight,
+ visibleParticipants,
+ ]
+ );
+
+ const handlePrevClick = () => setPage((p) => p - 1);
+ const handleNextClick = () => setPage((p) => p + 1);
+
+ return (
+
+
+
+
{tiles}
+
+
+
+
+
+ );
+};
+
+GridView.propTypes = {
+ maxTilesPerPage: PropTypes.number,
+};
+
+export default GridView;
diff --git a/custom/fitness-demo/components/GridView/index.js b/custom/fitness-demo/components/GridView/index.js
new file mode 100644
index 0000000..5df5398
--- /dev/null
+++ b/custom/fitness-demo/components/GridView/index.js
@@ -0,0 +1,2 @@
+export { GridView as default } from './GridView';
+export { GridView } from './GridView';
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js b/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js
new file mode 100644
index 0000000..b651402
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js
@@ -0,0 +1,72 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import Tile from '@custom/shared/components/Tile';
+import { DEFAULT_ASPECT_RATIO } from '@custom/shared/constants';
+import { useResize } from '@custom/shared/hooks/useResize';
+import PropTypes from 'prop-types';
+
+const MAX_RATIO = DEFAULT_ASPECT_RATIO;
+const MIN_RATIO = 4 / 3;
+
+export const SpeakerTile = ({ participant, screenRef }) => {
+ const [ratio, setRatio] = useState(MAX_RATIO);
+ const [nativeAspectRatio, setNativeAspectRatio] = useState(null);
+ const [screenHeight, setScreenHeight] = useState(1);
+
+ const updateRatio = useCallback(() => {
+ const rect = screenRef.current?.getBoundingClientRect();
+ setRatio(rect.width / rect.height);
+ setScreenHeight(rect.height);
+ }, [screenRef]);
+
+ useResize(() => {
+ updateRatio();
+ }, [updateRatio]);
+
+ useEffect(() => updateRatio(), [updateRatio]);
+
+ /**
+ * Only use the video's native aspect ratio if it's in portrait mode
+ * (e.g. mobile) to update how we crop videos. Otherwise, use landscape
+ * defaults.
+ */
+ const handleNativeAspectRatio = (r) => {
+ const isPortrait = r < 1;
+ setNativeAspectRatio(isPortrait ? r : null);
+ };
+
+ const { height, finalRatio, videoFit } = useMemo(
+ () =>
+ // Avoid cropping mobile videos, which have the nativeAspectRatio set
+ ({
+ height: (nativeAspectRatio ?? ratio) >= MIN_RATIO ? '100%' : null,
+ finalRatio:
+ nativeAspectRatio || (ratio <= MIN_RATIO ? MIN_RATIO : MAX_RATIO),
+ videoFit: ratio >= MAX_RATIO || nativeAspectRatio ? 'contain' : 'cover',
+ }),
+ [nativeAspectRatio, ratio]
+ );
+
+ const style = {
+ height,
+ maxWidth: screenHeight * finalRatio,
+ overflow: 'hidden',
+ };
+
+ return (
+
+ );
+};
+
+SpeakerTile.propTypes = {
+ participant: PropTypes.object,
+ screenRef: PropTypes.object,
+};
+
+export default SpeakerTile;
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js b/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js
new file mode 100644
index 0000000..84639df
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js
@@ -0,0 +1,2 @@
+export { SpeakerTile as default } from './SpeakerTile';
+export { SpeakerTile } from './SpeakerTile';
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerView.js b/custom/fitness-demo/components/SpeakerView/SpeakerView.js
new file mode 100644
index 0000000..2aaeb1c
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerView.js
@@ -0,0 +1,113 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import { Container } from '@custom/basic-call/components/Call/Container';
+import Header from '@custom/basic-call/components/Call/Header';
+import ParticipantBar from '@custom/shared/components/ParticipantBar/ParticipantBar';
+import VideoContainer from '@custom/shared/components/VideoContainer/VideoContainer';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useTracks } from '@custom/shared/contexts/TracksProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { isScreenId } from '@custom/shared/contexts/participantsState';
+import { SpeakerTile } from './SpeakerTile';
+
+const SIDEBAR_WIDTH = 186;
+
+export const SpeakerView = () => {
+ const { currentSpeaker, localParticipant, participants, screens } =
+ useParticipants();
+ const { updateCamSubscriptions } = useTracks();
+ const { showLocalVideo } = useCallState();
+ const { pinnedId } = useUIState();
+ const activeRef = useRef();
+
+ const screensAndPinned = useMemo(
+ () => [...screens, ...participants.filter(({ id }) => id === pinnedId)],
+ [participants, pinnedId, screens]
+ );
+
+ const otherParticipants = useMemo(
+ () => participants.filter(({ isLocal }) => !isLocal),
+ [participants]
+ );
+
+ const showSidebar = useMemo(() => {
+ const hasScreenshares = screens.length > 0;
+
+ if (isScreenId(pinnedId)) {
+ return false;
+ }
+
+ return participants.length > 1 || hasScreenshares;
+ }, [participants, pinnedId, screens]);
+
+ /* const screenShareTiles = useMemo(
+ () => ,
+ [screensAndPinned]
+ ); */
+
+ const hasScreenshares = useMemo(() => screens.length > 0, [screens]);
+
+ const fixedItems = useMemo(() => {
+ const items = [];
+ if (showLocalVideo) {
+ items.push(localParticipant);
+ }
+ if (hasScreenshares && otherParticipants.length > 0) {
+ items.push(otherParticipants[0]);
+ }
+ return items;
+ }, [hasScreenshares, localParticipant, otherParticipants, showLocalVideo]);
+
+ const otherItems = useMemo(() => {
+ if (otherParticipants.length > 1) {
+ return otherParticipants.slice(hasScreenshares ? 1 : 0);
+ }
+ return [];
+ }, [hasScreenshares, otherParticipants]);
+
+ /**
+ * Update cam subscriptions, in case ParticipantBar is not shown.
+ */
+ useEffect(() => {
+ // Sidebar takes care of cam subscriptions for all displayed participants.
+ if (showSidebar) return;
+ updateCamSubscriptions([
+ currentSpeaker?.id,
+ ...screensAndPinned.map((p) => p.id),
+ ]);
+ }, [currentSpeaker, screensAndPinned, showSidebar, updateCamSubscriptions]);
+
+ return (
+
+
+
+
+ {showSidebar && (
+
+ )}
+
+
+
+ );
+};
+
+export default SpeakerView;
diff --git a/custom/fitness-demo/components/SpeakerView/index.js b/custom/fitness-demo/components/SpeakerView/index.js
new file mode 100644
index 0000000..aa06afe
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/index.js
@@ -0,0 +1,2 @@
+export { SpeakerView as default } from './SpeakerView';
+export { SpeakerView } from './SpeakerView';
diff --git a/custom/fitness-demo/components/Tray/View.js b/custom/fitness-demo/components/Tray/View.js
new file mode 100644
index 0000000..6740dbb
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/View.js
@@ -0,0 +1,27 @@
+import React from 'react';
+
+import { TrayButton } from '@custom/shared/components/Tray';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState, VIEW_MODE_GRID, VIEW_MODE_SPEAKER } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconGridView } from '@custom/shared/icons/grid-md.svg';
+import { ReactComponent as IconSpeakerView } from '@custom/shared/icons/speaker-view-md.svg';
+
+export const ViewTray = () => {
+ const { participants } = useParticipants();
+ const { viewMode, setPreferredViewMode } = useUIState();
+
+ const onClick = () =>
+ setPreferredViewMode(viewMode === VIEW_MODE_SPEAKER ? VIEW_MODE_GRID: VIEW_MODE_SPEAKER);
+
+ return (
+
+ {viewMode === VIEW_MODE_SPEAKER ? : }
+
+ );
+};
+
+export default ViewTray;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/Tray/index.js b/custom/fitness-demo/components/Tray/index.js
index ea94d25..b68da84 100644
--- a/custom/fitness-demo/components/Tray/index.js
+++ b/custom/fitness-demo/components/Tray/index.js
@@ -3,6 +3,7 @@ import ChatTray from './Chat';
import RecordTray from './Record';
import ScreenShareTray from './ScreenShare';
import StreamTray from './Stream';
+import ViewTray from './View';
export const Tray = () => {
return (
@@ -11,6 +12,7 @@ export const Tray = () => {
+
>
);
};
diff --git a/custom/shared/icons/grid-md.svg b/custom/shared/icons/grid-md.svg
new file mode 100644
index 0000000..10fbfef
--- /dev/null
+++ b/custom/shared/icons/grid-md.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/custom/shared/icons/speaker-view-md.svg b/custom/shared/icons/speaker-view-md.svg
new file mode 100644
index 0000000..5d1f6f5
--- /dev/null
+++ b/custom/shared/icons/speaker-view-md.svg
@@ -0,0 +1 @@
+
\ No newline at end of file