From f2bc235cc1dae1558a42a08ded3c8fca0e5d7fdf Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 8 Jul 2021 15:22:59 +0100 Subject: [PATCH] updates from ENG-2175 --- dailyjs/{basic-call => }/.gitignore | 0 dailyjs/README.md | 4 + dailyjs/basic-call/components/App/App.js | 4 +- dailyjs/basic-call/components/Room/Room.js | 9 +- dailyjs/pagination/.babelrc | 4 + dailyjs/pagination/README.md | 2 + dailyjs/pagination/components/App/App.js | 20 ++ dailyjs/pagination/components/App/index.js | 1 + .../PaginatedVideoGrid/PaginatedVideoGrid.js | 315 ++++++++++++++++++ .../components/PaginatedVideoGrid/index.js | 1 + dailyjs/pagination/env.example | 5 + dailyjs/pagination/pages/_app.js | 5 + dailyjs/shared/contexts/TracksProvider.js | 76 ++--- dailyjs/shared/contexts/tracksState.js | 19 +- dailyjs/shared/hooks/useActiveSpeaker.js | 29 ++ 15 files changed, 433 insertions(+), 61 deletions(-) rename dailyjs/{basic-call => }/.gitignore (100%) create mode 100644 dailyjs/pagination/.babelrc create mode 100644 dailyjs/pagination/components/App/App.js create mode 100644 dailyjs/pagination/components/App/index.js create mode 100644 dailyjs/pagination/components/PaginatedVideoGrid/PaginatedVideoGrid.js create mode 100644 dailyjs/pagination/components/PaginatedVideoGrid/index.js create mode 100644 dailyjs/pagination/env.example create mode 100644 dailyjs/shared/hooks/useActiveSpeaker.js diff --git a/dailyjs/basic-call/.gitignore b/dailyjs/.gitignore similarity index 100% rename from dailyjs/basic-call/.gitignore rename to dailyjs/.gitignore diff --git a/dailyjs/README.md b/dailyjs/README.md index 7d20ecb..a927fec 100644 --- a/dailyjs/README.md +++ b/dailyjs/README.md @@ -12,6 +12,10 @@ Send messages to other participants using sendAppMessage Broadcast call to a custom RTMP endpoint using a variety of difference layout modes +### [📃 Pagination](./pagination) + +Demonstrates using manual track management to support larger call sizes + --- ## Getting started diff --git a/dailyjs/basic-call/components/App/App.js b/dailyjs/basic-call/components/App/App.js index 675af61..45b4c07 100644 --- a/dailyjs/basic-call/components/App/App.js +++ b/dailyjs/basic-call/components/App/App.js @@ -7,12 +7,13 @@ import Room from '../Room'; import { Asides } from './Asides'; import { Modals } from './Modals'; -export const App = () => { +export const App = ({ customComponentForState }) => { const { state } = useCallState(); const componentForState = useCallUI({ state, room: () => , + ...customComponentForState, }); // Memoize children to avoid unnecassary renders from HOC @@ -41,6 +42,7 @@ export const App = () => { App.propTypes = { asides: PropTypes.arrayOf(PropTypes.func), + customComponentsForState: PropTypes.any, }; export default App; diff --git a/dailyjs/basic-call/components/Room/Room.js b/dailyjs/basic-call/components/Room/Room.js index 33d8cbd..4578f91 100644 --- a/dailyjs/basic-call/components/Room/Room.js +++ b/dailyjs/basic-call/components/Room/Room.js @@ -9,10 +9,11 @@ import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider'; import { useWaitingRoom } from '@dailyjs/shared/contexts/WaitingRoomProvider'; import useJoinSound from '@dailyjs/shared/hooks/useJoinSound'; +import PropTypes from 'prop-types'; import { VideoGrid } from '../VideoGrid'; import { Header } from './Header'; -export const Room = () => { +export const Room = ({ MainComponent = VideoGrid }) => { const { setShowModal, showModal } = useWaitingRoom(); const { localParticipant } = useParticipants(); @@ -23,7 +24,7 @@ export const Room = () => {
- +
{/* Show waiting room notification & modal if call owner */} @@ -61,4 +62,8 @@ export const Room = () => { ); }; +Room.propTypes = { + MainComponent: PropTypes.node, +}; + export default Room; diff --git a/dailyjs/pagination/.babelrc b/dailyjs/pagination/.babelrc new file mode 100644 index 0000000..a6f4434 --- /dev/null +++ b/dailyjs/pagination/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["inline-react-svg"] +} diff --git a/dailyjs/pagination/README.md b/dailyjs/pagination/README.md index 261a139..55a599d 100644 --- a/dailyjs/pagination/README.md +++ b/dailyjs/pagination/README.md @@ -25,6 +25,8 @@ yarn yarn workspace @dailyjs/live-streaming dev ``` +Note that this example uses a env `MANUAL_TRACK_SUBS=1` which will disable [automatic track management](https://docs.daily.co/reference#%EF%B8%8F-setsubscribetotracksautomatically). + ## How does this example work? When call sizes exceed a certain volume (~12 or more particpants) it's important to start optimising for both bandwidth and CPU. Using manual track subscriptions allows each client to specify which participants they want to receive video and/or audio from, reducing how much data needs to be downloaded as well as the number of connections our servers maintain (subsequently supporting increased participant counts.) diff --git a/dailyjs/pagination/components/App/App.js b/dailyjs/pagination/components/App/App.js new file mode 100644 index 0000000..8e5733b --- /dev/null +++ b/dailyjs/pagination/components/App/App.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import App from '@dailyjs/basic-call/components/App'; +import Room from '@dailyjs/basic-call/components/Room'; + +const Test = () =>
Hello
; + +/** + * Rather than create an entirely new Room component we'll + * pass use the one in basic call with a custom MainComponent + */ +export const AppWithPagination = () => ( + , + }} + /> +); + +export default AppWithPagination; diff --git a/dailyjs/pagination/components/App/index.js b/dailyjs/pagination/components/App/index.js new file mode 100644 index 0000000..2851771 --- /dev/null +++ b/dailyjs/pagination/components/App/index.js @@ -0,0 +1 @@ +export { AppWithPagination as default } from './App'; diff --git a/dailyjs/pagination/components/PaginatedVideoGrid/PaginatedVideoGrid.js b/dailyjs/pagination/components/PaginatedVideoGrid/PaginatedVideoGrid.js new file mode 100644 index 0000000..ead7074 --- /dev/null +++ b/dailyjs/pagination/components/PaginatedVideoGrid/PaginatedVideoGrid.js @@ -0,0 +1,315 @@ +/* global rtcpeers */ + +import React, { + useCallback, + useMemo, + useEffect, + useRef, + useState, +} from 'react'; + +import Tile from '@dailyjs/shared/components/Tile'; +import { DEFAULT_ASPECT_RATIO } from '@dailyjs/shared/constants'; +import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider'; +import { useTracks } from '@dailyjs/shared/contexts/TracksProvider'; +import { useActiveSpeaker } from '@dailyjs/shared/hooks/useActiveSpeaker'; +import sortByKey from '@dailyjs/shared/lib/sortByKey'; + +import { useDeepCompareMemo } from 'use-deep-compare'; + +const MIN_TILE_WIDTH = 280; +const MAX_TILES_PER_PAGE = 12; + +export const PaginatedVideoGrid = () => { + const { + activeParticipant, + participantCount, + participants, + swapParticipantPosition, + } = useParticipants(); + const activeSpeakerId = useActiveSpeaker(); + + const { maxCamSubscriptions, updateCamSubscriptions } = useTracks(); + + const displayableParticipantCount = useMemo( + () => participantCount, + [participantCount] + ); + + const [dimensions, setDimensions] = useState({ + width: 1, + height: 1, + }); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [maxTilesPerPage] = useState(MAX_TILES_PER_PAGE); + + const gridRef = useRef(null); + + // 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); + }; + }, []); + + 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]); + + const pageSize = useMemo( + () => Math.min(maxColumns * maxRows, maxTilesPerPage), + [maxColumns, maxRows, maxTilesPerPage] + ); + + useEffect(() => { + setPages(Math.ceil(displayableParticipantCount / pageSize)); + }, [pageSize, displayableParticipantCount]); + + useEffect(() => { + if (page <= pages) return; + setPage(pages); + }, [page, pages]); + + 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]); + + 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 + */ + const camSubscriptions = useMemo(() => { + const maxSubs = maxCamSubscriptions + ? // avoid subscribing to only a portion of a page + Math.max(maxCamSubscriptions, pageSize) + : // if no maximum is set, subscribe to adjacent pages + 3 * pageSize; + + // Determine participant ids to subscribe to, based on page. + let subscribedIds = []; + switch (page) { + // First page + case 1: + subscribedIds = participants + .slice(0, Math.min(maxSubs, 2 * pageSize)) + .map((p) => p.id); + break; + // Last page + case Math.ceil(participants.length / pageSize): + subscribedIds = 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; + subscribedIds = participants.slice(min, max).map((p) => p.id); + } + break; + } + + // Determine subscribed, but invisible (= paused) video tracks. + const invisibleSubscribedIds = subscribedIds.filter( + (id) => id !== 'local' && !visibleParticipants.some((vp) => vp.id === id) + ); + return { + subscribedIds: subscribedIds.filter((id) => id !== 'local'), + pausedIds: invisibleSubscribedIds, + }; + }, [maxCamSubscriptions, page, pageSize, participants, visibleParticipants]); + + useEffect(() => { + const timeout = setTimeout(() => { + updateCamSubscriptions( + camSubscriptions?.subscribedIds, + camSubscriptions?.pausedIds + ); + }, 50); + return () => { + clearTimeout(timeout); + }; + }, [ + camSubscriptions?.subscribedIds, + camSubscriptions?.pausedIds, + updateCamSubscriptions, + ]); + + /** + * Set bandwidth layer based on amount of visible participants + */ + useEffect(() => { + if (typeof rtcpeers === 'undefined' || rtcpeers?.getCurrentType() !== 'sfu') + return; + + const sfu = rtcpeers.soup; + const count = visibleParticipants.length; + + visibleParticipants.forEach(({ id }) => { + if (count < 5) { + // High quality video for calls with < 5 people per page + sfu.setPreferredLayerForTrack(id, 'cam-video', 2); + } else if (count < 10) { + // Medium quality video for calls with < 10 people per page + sfu.setPreferredLayerForTrack(id, 'cam-video', 1); + } else { + // Low quality video for calls with 10 or more people per page + sfu.setPreferredLayerForTrack(id, 'cam-video', 0); + } + }); + }, [visibleParticipants]); + + /** + * 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 ( +
+ {pages > 1 && page > 1 && ( + + )} +
{tiles}
+ {pages > 1 && page < pages && ( + + )} + +
+ ); +}; + +export default PaginatedVideoGrid; diff --git a/dailyjs/pagination/components/PaginatedVideoGrid/index.js b/dailyjs/pagination/components/PaginatedVideoGrid/index.js new file mode 100644 index 0000000..ea21f7a --- /dev/null +++ b/dailyjs/pagination/components/PaginatedVideoGrid/index.js @@ -0,0 +1 @@ +export { PaginatedVideoGrid as default } from './PaginatedVideoGrid'; diff --git a/dailyjs/pagination/env.example b/dailyjs/pagination/env.example new file mode 100644 index 0000000..b064186 --- /dev/null +++ b/dailyjs/pagination/env.example @@ -0,0 +1,5 @@ +DAILY_DOMAIN= +DAILY_API_KEY= +DAILY_REST_DOMAIN=https://api.daily.co/v1 +DAILY_ROOM= +MANUAL_TRACK_SUBS=1 \ No newline at end of file diff --git a/dailyjs/pagination/pages/_app.js b/dailyjs/pagination/pages/_app.js index c9a5152..2204a2c 100644 --- a/dailyjs/pagination/pages/_app.js +++ b/dailyjs/pagination/pages/_app.js @@ -1,3 +1,8 @@ +import React from 'react'; + import App from '@dailyjs/basic-call/pages/_app'; +import AppWithPagination from '../components/App'; + +App.customAppComponent = ; export default App; diff --git a/dailyjs/shared/contexts/TracksProvider.js b/dailyjs/shared/contexts/TracksProvider.js index 92003ff..993ce96 100644 --- a/dailyjs/shared/contexts/TracksProvider.js +++ b/dailyjs/shared/contexts/TracksProvider.js @@ -20,7 +20,6 @@ import { REMOVE_TRACKS, TRACK_STARTED, TRACK_STOPPED, - UPDATE_SUBSCRIPTIONS, tracksReducer, } from './tracksState'; @@ -54,9 +53,7 @@ export const TracksProvider = ({ children }) => { ); const pauseVideoTrack = useCallback((id) => { - /** - * Ignore undefined, local or screenshare. - */ + // Ignore undefined, local or screenshare if (!id || isLocalId(id) || isScreenId(id)) return; if (!rtcpeers.soup.implementationIsAcceptingCalls) { return; @@ -68,9 +65,7 @@ export const TracksProvider = ({ children }) => { const resumeVideoTrack = useCallback( (id) => { - /** - * Ignore undefined, local or screenshare. - */ + // Ignore undefined, local or screenshare if (!id || isLocalId(id) || isScreenId(id)) return; const videoTrack = callObject.participants()?.[id]?.tracks?.video; @@ -108,16 +103,34 @@ export const TracksProvider = ({ children }) => { remoteParticipantIds.length <= SUBSCRIBE_ALL_VIDEO_THRESHOLD ? [...remoteParticipantIds] : [...ids, ...recentSpeakerIds]; + const updates = remoteParticipantIds.reduce((u, id) => { const shouldSubscribe = subscribedIds.includes(id); + const shouldPause = pausedIds.includes(id); const isSubscribed = callObject.participants()?.[id]?.tracks?.video?.subscribed; + + // Set resume state for newly subscribed tracks + if (shouldSubscribe) { + rtcpeers.soup.setResumeOnSubscribeForTrack( + id, + 'cam-video', + !pausedIds.includes(id) + ); + } + + // Pause already subscribed tracks + if (shouldSubscribe && shouldPause) { + pauseVideoTrack(id); + } + if ( isLocalId(id) || isScreenId(id) || (shouldSubscribe && isSubscribed) ) return u; + const result = { setSubscribedTracks: { audio: true, @@ -129,19 +142,7 @@ export const TracksProvider = ({ children }) => { return { ...u, [id]: result }; }, {}); - dispatch({ - type: UPDATE_SUBSCRIPTIONS, - subscriptions: { - video: subscribedIds.reduce((v, id) => { - const result = { - id, - paused: pausedIds.includes(id) || !ids.includes(id), - }; - return { ...v, ...result }; - }, {}), - }, - }); - + // Fast resume already subscribed videos ids .filter((id) => !pausedIds.includes(id)) .forEach((id) => { @@ -153,23 +154,24 @@ export const TracksProvider = ({ children }) => { callObject.updateParticipants(updates); }, - [callObject, remoteParticipantIds, recentSpeakerIds, resumeVideoTrack] + [ + callObject, + remoteParticipantIds, + recentSpeakerIds, + pauseVideoTrack, + resumeVideoTrack, + ] ); useEffect(() => { - if (!callObject) { - return false; - } + if (!callObject) return false; const trackStoppedQueue = []; const handleTrackStarted = ({ participant, track }) => { - if (state.subscriptions.video?.[participant.session_id]?.paused) { - pauseVideoTrack(participant.session_id); - } /** * If track for participant was recently stopped, remove it from queue, - * so we don't run into a stale state. + * so we don't run into a stale state */ const stoppingIdx = trackStoppedQueue.findIndex( ([p, t]) => @@ -186,9 +188,7 @@ export const TracksProvider = ({ children }) => { }; const trackStoppedBatchInterval = setInterval(() => { - if (!trackStoppedQueue.length) { - return; - } + if (!trackStoppedQueue.length) return; dispatch({ type: TRACK_STOPPED, items: trackStoppedQueue.splice(0, trackStoppedQueue.length), @@ -248,25 +248,17 @@ export const TracksProvider = ({ children }) => { callObject.off('participant-joined', handleParticipantJoined); callObject.off('participant-left', handleParticipantLeft); }; - }, [callObject, pauseVideoTrack, state.subscriptions.video]); - - useEffect(() => { - Object.values(state.subscriptions.video).forEach(({ id, paused }) => { - if (paused) { - pauseVideoTrack(id); - } - }); - }, [pauseVideoTrack, state.subscriptions.video]); + }, [callObject, pauseVideoTrack]); return ( diff --git a/dailyjs/shared/contexts/tracksState.js b/dailyjs/shared/contexts/tracksState.js index cda0054..a168fc5 100644 --- a/dailyjs/shared/contexts/tracksState.js +++ b/dailyjs/shared/contexts/tracksState.js @@ -11,9 +11,6 @@ import { getId, getScreenId } from './participantsState'; const initialTracksState = { audioTracks: {}, videoTracks: {}, - subscriptions: { - video: {}, - }, }; // --- Actions --- @@ -21,7 +18,6 @@ const initialTracksState = { const TRACK_STARTED = 'TRACK_STARTED'; const TRACK_STOPPED = 'TRACK_STOPPED'; const REMOVE_TRACKS = 'REMOVE_TRACKS'; -const UPDATE_SUBSCRIPTIONS = 'UPDATE_SUBSCRIPTIONS'; // --- Reducer and helpers -- @@ -65,11 +61,11 @@ function tracksReducer(prevState, action) { }, }; } + case TRACK_STOPPED: { - const { audioTracks, subscriptions, videoTracks } = prevState; + const { audioTracks, videoTracks } = prevState; const newAudioTracks = { ...audioTracks }; - const newSubscriptions = { ...subscriptions }; const newVideoTracks = { ...videoTracks }; action.items.forEach(([participant, track]) => { @@ -94,13 +90,12 @@ function tracksReducer(prevState, action) { return { audioTracks: newAudioTracks, - subscriptions: newSubscriptions, videoTracks: newVideoTracks, }; } case REMOVE_TRACKS: { - const { audioTracks, subscriptions, videoTracks } = prevState; + const { audioTracks, videoTracks } = prevState; const id = getId(action.participant); const screenId = getScreenId(id); @@ -111,17 +106,10 @@ function tracksReducer(prevState, action) { return { audioTracks, - subscriptions, videoTracks, }; } - case UPDATE_SUBSCRIPTIONS: - return { - ...prevState, - subscriptions: action.subscriptions, - }; - default: throw new Error(); } @@ -133,5 +121,4 @@ export { REMOVE_TRACKS, TRACK_STARTED, TRACK_STOPPED, - UPDATE_SUBSCRIPTIONS, }; diff --git a/dailyjs/shared/hooks/useActiveSpeaker.js b/dailyjs/shared/hooks/useActiveSpeaker.js new file mode 100644 index 0000000..c6e0a21 --- /dev/null +++ b/dailyjs/shared/hooks/useActiveSpeaker.js @@ -0,0 +1,29 @@ +import { useCallState } from '../contexts/CallProvider'; +import { useParticipants } from '../contexts/ParticipantsProvider'; + +/** + * Convenience hook to contain all logic on determining the active speaker + * (= the current one and only actively speaking person) + */ +export const useActiveSpeaker = () => { + const { showLocalVideo } = useCallState(); + const { activeParticipant, localParticipant, participantCount } = + useParticipants(); + + // we don't show active speaker indicators EVER in a 1:1 call or when the user is alone in-call + if (participantCount <= 2) return null; + + if (!activeParticipant?.isMicMuted) { + return activeParticipant?.id; + } + + /** + * When the local video is displayed and the last known active speaker + * is muted, we can only fall back to the local participant. + */ + return localParticipant?.isMicMuted || !showLocalVideo + ? null + : localParticipant?.id; +}; + +export default useActiveSpeaker;