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 @@ +grid_on \ 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 @@ +view_sidebar \ No newline at end of file