updates from ENG-2175

This commit is contained in:
Jon 2021-07-08 15:22:59 +01:00
parent e01c70c33e
commit f2bc235cc1
15 changed files with 433 additions and 61 deletions

View File

@ -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

View File

@ -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: () => <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;

View File

@ -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 = () => {
<Header />
<main>
<VideoGrid />
<MainComponent />
</main>
{/* Show waiting room notification & modal if call owner */}
@ -61,4 +62,8 @@ export const Room = () => {
);
};
Room.propTypes = {
MainComponent: PropTypes.node,
};
export default Room;

View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": ["inline-react-svg"]
}

View File

@ -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.)

View File

@ -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 = () => <div>Hello</div>;
/**
* Rather than create an entirely new Room component we'll
* pass use the one in basic call with a custom MainComponent
*/
export const AppWithPagination = () => (
<App
customComponentForState={{
room: () => <Room MainComponent={Test} />,
}}
/>
);
export default AppWithPagination;

View File

@ -0,0 +1 @@
export { AppWithPagination as default } from './App';

View File

@ -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) => (
<Tile
participant={p}
mirrored
style={{
maxHeight: tileHeight,
maxWidth: tileWidth,
}}
/>
)),
[
activeParticipant,
participantCount,
tileWidth,
tileHeight,
visibleParticipants,
]
);
const handlePrevClick = () => setPage((p) => p - 1);
const handleNextClick = () => setPage((p) => p + 1);
return (
<div ref={gridRef} className="grid">
{pages > 1 && page > 1 && (
<button type="button" onClick={handlePrevClick}>
&laquo;
</button>
)}
<div>{tiles}</div>
{pages > 1 && page < pages && (
<button type="button" onClick={handleNextClick}>
&raquo;
</button>
)}
<style jsx>{`
.grid {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
width: 100%;
}
.tiles {
align-items: center;
display: flex;
flex-flow: row wrap;
gap: 1px;
max-height: 100%;
justify-content: center;
margin: auto;
overflow: hidden;
width: 100%;
}
`}</style>
</div>
);
};
export default PaginatedVideoGrid;

View File

@ -0,0 +1 @@
export { PaginatedVideoGrid as default } from './PaginatedVideoGrid';

View File

@ -0,0 +1,5 @@
DAILY_DOMAIN=
DAILY_API_KEY=
DAILY_REST_DOMAIN=https://api.daily.co/v1
DAILY_ROOM=
MANUAL_TRACK_SUBS=1

View File

@ -1,3 +1,8 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithPagination from '../components/App';
App.customAppComponent = <AppWithPagination />;
export default App;

View File

@ -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 (
<TracksContext.Provider
value={{
audioTracks: state.audioTracks,
videoTracks: state.videoTracks,
pauseVideoTrack,
resumeVideoTrack,
remoteParticipantIds,
updateCamSubscriptions,
videoTracks: state.videoTracks,
remoteParticipantIds,
recentSpeakerIds,
}}
>

View File

@ -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,
};

View File

@ -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;