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;