merge pagination

This commit is contained in:
Jon 2021-07-20 14:10:45 +01:00
commit 7923cc7856
42 changed files with 1129 additions and 301 deletions

35
dailyjs/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
/coverage
# next.js
.next
out
# production
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

View File

@ -20,6 +20,10 @@ Record a call video and audio using both cloud and local modes
Send emoji reactions to all clients using sendAppMessage
### [📃 Pagination](./pagination)
Demonstrates using manual track management to support larger call sizes
---
## Getting started

View File

@ -3,16 +3,18 @@ import ExpiryTimer from '@dailyjs/shared/components/ExpiryTimer';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useCallUI } from '@dailyjs/shared/hooks/useCallUI';
import PropTypes from 'prop-types';
import Room from '../Room';
import { Asides } from './Asides';
import { Modals } from './Modals';
export const App = () => {
export const App = ({ customComponentForState }) => {
const { roomExp, state } = useCallState();
const componentForState = useCallUI({
state,
room: () => <Room />,
...customComponentForState,
});
// Memoize children to avoid unnecassary renders from HOC
@ -42,4 +44,8 @@ export const App = () => {
);
};
App.propTypes = {
customComponentForState: PropTypes.any,
};
export default App;

View File

@ -13,7 +13,7 @@ export const Header = () => {
<div className="capsule">Basic call demo</div>
<div className="capsule">
{`${participantCount} ${
participantCount > 1 ? 'participants' : 'participant'
participantCount === 1 ? 'participant' : 'participants'
}`}
</div>
{customCapsule && (

View File

@ -6,7 +6,7 @@ import useJoinSound from '@dailyjs/shared/hooks/useJoinSound';
import PropTypes from 'prop-types';
import WaitingRoom from '../WaitingRoom';
const RoomContainer = ({ children }) => {
export const RoomContainer = ({ children }) => {
const { localParticipant } = useParticipants();
const isOwner = !!localParticipant?.isOwner;
@ -17,10 +17,8 @@ const RoomContainer = ({ children }) => {
<>
{/* Show waiting room notification & modal if call owner */}
{isOwner && <WaitingRoom />}
{/* Tray buttons */}
<BasicTray />
{/* Audio tags */}
<Audio />
</>
@ -59,4 +57,4 @@ RoomContainer.propTypes = {
children: PropTypes.node,
};
export default React.memo(RoomContainer, () => true);
export default RoomContainer;

View File

@ -1 +1,2 @@
export { Room as default } from './Room';
export { RoomContainer } from './RoomContainer';

View File

@ -2,19 +2,30 @@ import React, { useState, useMemo, useEffect, useRef } 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 usePreferredLayer from '@dailyjs/shared/hooks/usePreferredLayer';
import { useDeepCompareMemo } from 'use-deep-compare';
/**
* 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 } = useParticipants();
const { resumeVideoTrack } = useTracks();
const { participants, allParticipants } = useParticipants();
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 = () => {
@ -35,9 +46,10 @@ export const VideoGrid = React.memo(
};
}, []);
// Basic brute-force packing algo
const layout = useMemo(() => {
const aspectRatio = DEFAULT_ASPECT_RATIO;
const tileCount = allParticipants.length || 0;
const tileCount = participants.length || 0;
const w = dimensions.width;
const h = dimensions.height;
@ -76,11 +88,12 @@ export const VideoGrid = React.memo(
}
return bestLayout;
}, [dimensions, allParticipants]);
}, [dimensions, participants]);
// Memoize our tile list to avoid unnecassary re-renders
const tiles = useDeepCompareMemo(
() =>
allParticipants.map((p) => (
participants.map((p) => (
<Tile
participant={p}
key={p.id}
@ -88,45 +101,14 @@ export const VideoGrid = React.memo(
style={{ maxWidth: layout.width, maxHeight: layout.height }}
/>
)),
[layout, allParticipants]
[layout, participants]
);
/**
* Set bandwidth layer based on amount of visible participants
*/
useEffect(() => {
if (
typeof rtcpeers === 'undefined' ||
// eslint-disable-next-line no-undef
rtcpeers?.getCurrentType() !== 'sfu'
)
return;
// Optimise performance by reducing video quality
// when more participants join (if in SFU mode)
usePreferredLayer(allParticipants);
// eslint-disable-next-line no-undef
const sfu = rtcpeers.soup;
const count = allParticipants.length;
allParticipants.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);
}
});
}, [allParticipants]);
useEffect(() => {
allParticipants.forEach(
(p) => p.id !== 'local' && resumeVideoTrack(p.id)
);
}, [allParticipants, resumeVideoTrack]);
if (!allParticipants.length) {
if (!participants.length) {
return null;
}

View File

@ -25,6 +25,7 @@ export default function Index({
predefinedRoom = '',
forceFetchToken = false,
forceOwner = false,
subscribeToTracksAutomatically = true,
demoMode = false,
asides,
modals,
@ -114,7 +115,12 @@ export default function Index({
modals={modals}
customTrayComponent={customTrayComponent}
>
<CallProvider domain={domain} room={roomName} token={token}>
<CallProvider
domain={domain}
room={roomName}
token={token}
subscribeToTracksAutomatically={subscribeToTracksAutomatically}
>
<ParticipantsProvider>
<TracksProvider>
<MediaDeviceProvider>
@ -139,12 +145,12 @@ Index.propTypes = {
customAppComponent: PropTypes.node,
forceFetchToken: PropTypes.bool,
forceOwner: PropTypes.bool,
subscribeToTracksAutomatically: PropTypes.bool,
demoMode: PropTypes.bool,
};
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: defaultProps,
};

View File

@ -3,12 +3,21 @@ import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import FlyingEmojiOverlay from '@dailyjs/flying-emojis/components/FlyingEmojis';
import { ChatProvider } from '@dailyjs/text-chat/contexts/ChatProvider';
import Room from '../Room';
/**
* We'll pass through our own custom Room for this example
* as the layout logic changes considerably for the basic demo
*/
export const LiveFitnessApp = () => (
<>
<ChatProvider>
<FlyingEmojiOverlay />
<App />
<App
customComponentForState={{
room: () => <Room />,
}}
/>
</ChatProvider>
</>
);

View File

@ -0,0 +1,11 @@
import React from 'react';
import RoomContainer from '@dailyjs/basic-call/components/Room/RoomContainer';
export const Room = () => (
<RoomContainer>
<main>Hello</main>
</RoomContainer>
);
export default RoomContainer;

View File

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

View File

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

View File

@ -0,0 +1,47 @@
# Pagination, Sorting & Track Management
![Pagination](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-pagination.vercel.app)**
---
## What does this demo do?
- Switches to [manual track subscriptions](https://docs.daily.co/reference#%EF%B8%8F-setsubscribetotracksautomatically) to pause / resume video tracks as they are paged in and out of view
- Introduces a new video grid component that manages pagination and sorts participant tiles based on their active speaker status
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/live-streaming dev
```
Note: this example uses an additional env `MANUAL_TRACK_SUBS=1` that 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.)
This demo introduces a new paginated grid component that subscribes to any tiles that are in view. Our subscription API allows for the subscribing, pausing, resuming and unsubscribing of tracks. The grid component will:
1. Subscribe to all participants on the current and adjacent pages.
2. Pause participants video if they are not in view (i.e. on the current page.) Pausing is optimal over unsubscribing in this particular use case since unsubscribing a track results in a full teardown of the data stream. Re-subscribing to a track is perceivably slower than pausing and resuming.
3. Play / resume participant's video when they are on the current page.
4. Unsubscribe from a participant's video if they are not on an adjacent page (explained below.)
When you pause a track, you are keeping the connection for that track open and connected to the SFU but stopping any bytes from flowing across that connection. Therefore, this simple approach of pausing a track when it is offscreen rather than completely unsubscribing (and tearing down that connection) speeds up the process of showing/hiding participant videos while also cutting out the processing and bandwidth required for those tracks.
It is important to note that a subscription and the underlying connections it entails does still result in some overhead and this approach breaks down once you get to even larger calls (e.g. ~50 or more depending on device, bandwidth, geolocation etc). In those scenarios it is best to take advantage of both pausing and unsubscribing to maximize both quickly showing videos and minimizing connections/processing/cpu. This example showcases how to do this by subscribing to the current page's videos (all videos resumed) as well as the adjacent pages' videos (all videos paused) and unsubscribing to any other pages' videos. This has the affect of minimizing the overall number of subscriptions while still having any video which may be displayed shortly, subscribed with their connections ready to be resumed as soon as the user pages over.
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,15 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import Room from '@dailyjs/basic-call/components/Room';
import PaginatedVideoGrid from '../PaginatedVideoGrid';
export const AppWithPagination = () => (
<App
customComponentForState={{
room: () => <Room MainComponent={PaginatedVideoGrid} />,
}}
/>
);
export default AppWithPagination;

View File

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

View File

@ -0,0 +1,314 @@
import React, {
useCallback,
useMemo,
useEffect,
useRef,
useState,
} from 'react';
import { Button } from '@dailyjs/shared/components/Button';
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 { useCamSubscriptions } from '@dailyjs/shared/hooks/useCamSubscriptions';
import usePreferredLayer from '@dailyjs/shared/hooks/usePreferredLayer';
import { ReactComponent as IconArrow } from '@dailyjs/shared/icons/raquo-md.svg';
import sortByKey from '@dailyjs/shared/lib/sortByKey';
import { debounce } from 'debounce';
import { useDeepCompareMemo } from 'use-deep-compare';
// --- Constants
const MIN_TILE_WIDTH = 280;
const MAX_TILES_PER_PAGE = 12;
export const PaginatedVideoGrid = () => {
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 [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);
};
}, []);
// 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]);
// 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
* Please refer to project README for more information
*/
const camSubscriptions = useMemo(() => {
const maxSubs = 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,
};
}, [page, pageSize, participants, visibleParticipants]);
useCamSubscriptions(
camSubscriptions?.subscribedIds,
camSubscriptions?.pausedIds
);
// Set bandwidth layer based on amount of visible participants
usePreferredLayer(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
key={p.id}
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">
<Button
className="page-button prev"
disabled={!(pages > 1 && page > 1)}
type="button"
onClick={handlePrevClick}
>
<IconArrow />
</Button>
<div className="tiles">{tiles}</div>
<Button
className="page-button next"
disabled={!(pages > 1 && page < pages)}
type="button"
onClick={handleNextClick}
>
<IconArrow />
</Button>
<style jsx>{`
.grid {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
width: 100%;
}
.grid .tiles {
align-items: center;
display: flex;
flex-flow: row wrap;
gap: 1px;
max-height: 100%;
justify-content: center;
margin: auto;
overflow: hidden;
width: 100%;
}
.grid :global(.page-button) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
height: 84px;
padding: 0px var(--spacing-xxxs) 0px var(--spacing-xxs);
background-color: var(--blue-default);
color: white;
border-right: 0px;
}
.grid :global(.page-button):disabled {
color: var(--blue-dark);
background-color: var(--blue-light);
border-color: var(--blue-light);
}
.grid :global(.page-button.prev) {
transform: scaleX(-1);
}
`}</style>
</div>
);
};
export default PaginatedVideoGrid;

View File

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

View File

@ -0,0 +1,24 @@
import React from 'react';
import { TrayButton } from '@dailyjs/shared/components/Tray';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { ReactComponent as IconAdd } from '@dailyjs/shared/icons/add-md.svg';
export const Tray = () => {
const { callObject } = useCallState();
return (
<>
<TrayButton
label="Add Fake"
onClick={() => {
callObject.addFakeParticipant();
}}
>
<IconAdd />
</TrayButton>
</>
);
};
export default Tray;

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@dailyjs/pagination",
"description": "Basic Call + Pagination",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",
"@dailyjs/basic-call": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

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

View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,16 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: {
...defaultProps,
forceFetchToken: true,
forceOwner: true,
},
};
}
export default Index;

1
dailyjs/pagination/public Symbolic link
View File

@ -0,0 +1 @@
../basic-call/public

View File

@ -18,8 +18,6 @@ export const DeviceSelect = () => {
return <div>Loading devices...</div>;
}
console.log(currentDevices);
return (
<>
<Field label="Select camera:">

View File

@ -30,7 +30,7 @@ import { useDeepCompareMemo } from 'use-deep-compare';
* - Set user name and join call / request access
*/
export const HairCheck = () => {
const { callObject } = useCallState();
const { callObject, join } = useCallState();
const { localParticipant } = useParticipants();
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
useMediaDevices();

View File

@ -28,7 +28,7 @@ export const CallProvider = ({
domain,
room,
token = '',
subscribeToTracksAutomatically = false,
subscribeToTracksAutomatically = true,
}) => {
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);

View File

@ -8,14 +8,14 @@ import React, {
useMemo,
} from 'react';
import PropTypes from 'prop-types';
import { useDeepCompareMemo } from 'use-deep-compare';
import { sortByKey } from '../lib/sortByKey';
import { useCallState } from './CallProvider';
import {
ACTIVE_SPEAKER,
initialParticipantsState,
isLocalId,
ACTIVE_SPEAKER,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
@ -26,7 +26,7 @@ import {
export const ParticipantsContext = createContext();
export const ParticipantsProvider = ({ children }) => {
const { broadcast, callObject } = useCallState();
const { callObject } = useCallState();
const [state, dispatch] = useReducer(
participantsReducer,
initialParticipantsState
@ -37,27 +37,21 @@ export const ParticipantsProvider = ({ children }) => {
/**
* ALL participants (incl. shared screens) in a convenient array
*/
const allParticipants = useDeepCompareMemo(
() => Object.values(state.participants),
[state?.participants]
const allParticipants = useMemo(
() => [...state.participants, ...state.screens],
[state?.participants, state?.screens]
);
/**
* Only return participants that should be visible in the call
*/
const participants = useDeepCompareMemo(
() =>
!broadcast
? allParticipants
: allParticipants.filter((p) => p?.isOwner || p?.isScreenshare),
[broadcast, allParticipants]
);
const participants = useMemo(() => state.participants, [state.participants]);
/**
* The number of participants, who are not a shared screen
* (technically a shared screen counts as a participant, but we shouldn't tell humans)
*/
const participantCount = useDeepCompareMemo(
const participantCount = useMemo(
() => participants.filter(({ isScreenshare }) => !isScreenshare).length,
[participants]
);
@ -65,7 +59,7 @@ export const ParticipantsProvider = ({ children }) => {
/**
* The participant who most recently got mentioned via a `active-speaker-change` event
*/
const activeParticipant = useDeepCompareMemo(
const activeParticipant = useMemo(
() => participants.find(({ isActiveSpeaker }) => isActiveSpeaker),
[participants]
);
@ -73,7 +67,7 @@ export const ParticipantsProvider = ({ children }) => {
/**
* The local participant
*/
const localParticipant = useDeepCompareMemo(
const localParticipant = useMemo(
() =>
allParticipants.find(
({ isLocal, isScreenshare }) => isLocal && !isScreenshare
@ -81,10 +75,7 @@ export const ParticipantsProvider = ({ children }) => {
[allParticipants]
);
const isOwner = useDeepCompareMemo(
() => localParticipant?.isOwner,
[localParticipant]
);
const isOwner = useMemo(() => localParticipant?.isOwner, [localParticipant]);
/**
* The participant who should be rendered prominently right now
@ -99,6 +90,19 @@ export const ParticipantsProvider = ({ children }) => {
const displayableParticipants = participants.filter((p) => !p?.isLocal);
if (
!isPresent &&
displayableParticipants.length > 0 &&
displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate)
) {
// Return first cam on participant in case everybody is muted and nobody ever talked
// or first remote participant, in case everybody's cam is muted, too.
return (
displayableParticipants.find((p) => !p.isCamMuted) ??
displayableParticipants?.[0]
);
}
const sorted = displayableParticipants
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
.reverse();
@ -109,7 +113,7 @@ export const ParticipantsProvider = ({ children }) => {
/**
* Screen shares
*/
const screens = useDeepCompareMemo(
const screens = useMemo(
() => allParticipants.filter(({ isScreenshare }) => isScreenshare),
[allParticipants]
);
@ -119,6 +123,25 @@ export const ParticipantsProvider = ({ children }) => {
*/
const username = callObject?.participants()?.local?.user_name ?? '';
const [muteNewParticipants, setMuteNewParticipants] = useState(false);
const muteAll = useCallback(
(muteFutureParticipants = false) => {
if (!localParticipant.isOwner) return;
setMuteNewParticipants(muteFutureParticipants);
const unmutedParticipants = participants.filter(
(p) => !p.isLocal && !p.isMicMuted
);
if (!unmutedParticipants.length) return;
const result = unmutedParticipants.reduce(
(o, p) => ({ ...o[p.id], setAudio: false }),
{}
);
callObject.updateParticipants(result);
},
[callObject, localParticipant, participants]
);
/**
* Sets the local participant's name in daily-js
* @param name The new username
@ -128,6 +151,7 @@ export const ParticipantsProvider = ({ children }) => {
};
const swapParticipantPosition = (id1, id2) => {
if (id1 === id2 || !id1 || !id2 || isLocalId(id1) || isLocalId(id2)) return;
dispatch({
type: SWAP_POSITION,
id1,
@ -222,6 +246,8 @@ export const ParticipantsProvider = ({ children }) => {
participantMarkedForRemoval,
participants,
screens,
muteNewParticipants,
muteAll,
setParticipantMarkedForRemoval,
setUsername,
swapParticipantPosition,

View File

@ -1,60 +1,232 @@
/* global rtcpeers */
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
} from 'react';
import PropTypes from 'prop-types';
import { sortByKey } from '../lib/sortByKey';
import { useCallState } from './CallProvider';
import { useParticipants } from './ParticipantsProvider';
import { isLocalId, isScreenId } from './participantsState';
import {
initialTracksState,
REMOVE_TRACKS,
TRACK_STARTED,
TRACK_STOPPED,
UPDATE_TRACKS,
tracksReducer,
} from './tracksState';
/**
* Maximum amount of concurrently subscribed most recent speakers.
*/
const MAX_RECENT_SPEAKER_COUNT = 6;
/**
* Threshold up to which all videos will be subscribed.
* If the remote participant count passes this threshold,
* cam subscriptions are defined by UI view modes.
*/
const SUBSCRIBE_ALL_VIDEO_THRESHOLD = 9;
const TracksContext = createContext(null);
export const TracksProvider = ({ children }) => {
const { callObject } = useCallState();
const { participants } = useParticipants();
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
const recentSpeakerIds = useMemo(
() =>
participants
.filter((p) => Boolean(p.lastActiveDate) && !p.isLocal)
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
.slice(-MAX_RECENT_SPEAKER_COUNT)
.map((p) => p.id)
.reverse(),
[participants]
);
const pauseVideoTrack = useCallback((id) => {
/**
* Ignore undefined, local or screenshare.
*/
if (
!id ||
isLocalId(id) ||
isScreenId(id) ||
rtcpeers.getCurrentType() !== 'sfu'
) {
return;
}
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
return;
}
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
if (!consumer) {
rtcpeers.soup.setResumeOnSubscribeForTrack(id, 'cam-video', false);
} else {
rtcpeers.soup.pauseConsumer(consumer);
}
}, []);
const resumeVideoTrack = useCallback(
(id) => {
/**
* Ignore undefined, local or screenshare.
*/
if (!id || isLocalId(id) || isScreenId(id)) return;
const videoTrack = callObject.participants()?.[id]?.tracks?.video;
const subscribe = () => {
if (videoTrack?.subscribed) return;
callObject.updateParticipant(id, {
setSubscribedTracks: true,
});
};
switch (rtcpeers.getCurrentType()) {
case 'peer-to-peer':
subscribe();
break;
case 'sfu': {
if (!rtcpeers.soup.implementationIsAcceptingCalls) return;
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
if (!(consumer && consumer.appData)) {
rtcpeers.soup.setResumeOnSubscribeForTrack(id, 'cam-video', true);
subscribe();
} else {
rtcpeers.soup.resumeConsumer(consumer);
}
break;
}
default:
break;
}
},
[callObject]
);
const remoteParticipantIds = useMemo(
() => participants.filter((p) => !p.isLocal).map((p) => p.id),
[participants]
);
/**
* Updates cam subscriptions based on passed ids.
*
* @param ids Array of ids to subscribe to, all others will be unsubscribed.
* @param pausedIds Array of ids that should be subscribed, but paused.
*/
const updateCamSubscriptions = useCallback(
(ids, pausedIds = []) => {
if (!callObject) return;
const subscribedIds =
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;
/**
* Pause already subscribed tracks.
*/
if (shouldSubscribe && shouldPause) {
pauseVideoTrack(id);
}
/**
* Fast resume tracks.
*/
if (shouldSubscribe && !shouldPause) {
resumeVideoTrack(id);
}
if (
isLocalId(id) ||
isScreenId(id) ||
(shouldSubscribe && isSubscribed)
) {
return u;
}
const result = {
setSubscribedTracks: {
audio: true,
screenAudio: true,
screenVideo: true,
video: shouldSubscribe,
},
};
return { ...u, [id]: result };
}, {});
callObject.updateParticipants(updates);
},
[
callObject,
remoteParticipantIds,
recentSpeakerIds,
pauseVideoTrack,
resumeVideoTrack,
]
);
useEffect(() => {
if (!callObject) return false;
const trackStoppedQueue = [];
const handleTrackStarted = ({ participant, track }) => {
/**
* If track for participant was recently stopped, remove it from queue,
* so we don't run into a stale state
*/
const stoppingIdx = trackStoppedQueue.findIndex(
([p, t]) =>
p.session_id === participant.session_id && t.kind === track.kind
);
if (stoppingIdx >= 0) {
trackStoppedQueue.splice(stoppingIdx, 1);
}
dispatch({
type: TRACK_STARTED,
participant,
track,
});
};
const trackStoppedBatchInterval = setInterval(() => {
if (!trackStoppedQueue.length) return;
dispatch({
type: TRACK_STOPPED,
items: trackStoppedQueue.splice(0, trackStoppedQueue.length),
});
}, 3000);
const handleTrackStopped = ({ participant, track }) => {
if (participant) {
dispatch({
type: TRACK_STOPPED,
participant,
track,
});
trackStoppedQueue.push([participant, track]);
}
};
const handleParticipantLeft = ({ participant }) => {
dispatch({
type: REMOVE_TRACKS,
participant,
});
};
const handleParticipantUpdated = ({ participant }) => {
dispatch({
type: UPDATE_TRACKS,
participant,
});
};
const joinedSubscriptionQueue = [];
@ -62,22 +234,26 @@ export const TracksProvider = ({ children }) => {
joinedSubscriptionQueue.push(participant.session_id);
};
const batchInterval = setInterval(() => {
const joinBatchInterval = setInterval(() => {
if (!joinedSubscriptionQueue.length) return;
const ids = joinedSubscriptionQueue.splice(0);
const participants = callObject.participants();
const callParticipants = callObject.participants();
const updates = ids.reduce((o, id) => {
const { subscribed } = participants?.[id]?.tracks?.audio;
const { subscribed } = callParticipants?.[id]?.tracks?.audio;
const result = { ...o[id] };
if (!subscribed) {
o[id] = {
setSubscribedTracks: {
audio: true,
screenAudio: true,
screenVideo: true,
},
result.setSubscribedTracks = {
audio: true,
screenAudio: true,
screenVideo: true,
};
}
return o;
if (rtcpeers?.getCurrentType?.() === 'peer-to-peer') {
result.setSubscribedTracks = true;
}
return { [id]: result };
}, {});
callObject.updateParticipants(updates);
}, 100);
@ -86,71 +262,26 @@ export const TracksProvider = ({ children }) => {
callObject.on('track-stopped', handleTrackStopped);
callObject.on('participant-joined', handleParticipantJoined);
callObject.on('participant-left', handleParticipantLeft);
callObject.on('participant-updated', handleParticipantUpdated);
return () => {
clearInterval(batchInterval);
clearInterval(joinBatchInterval);
clearInterval(trackStoppedBatchInterval);
callObject.off('track-started', handleTrackStarted);
callObject.off('track-stopped', handleTrackStopped);
callObject.off('participant-joined', handleParticipantJoined);
callObject.off('participant-left', handleParticipantLeft);
callObject.off('participant-updated', handleParticipantUpdated);
};
}, [callObject]);
const pauseVideoTrack = useCallback(
(id) => {
if (!callObject) return;
/**
* Ignore undefined, local or screenshare.
*/
if (!id || id.includes('local') || id.includes('screen')) return;
// eslint-disable-next-line
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
return;
}
// eslint-disable-next-line
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
if (!consumer) return;
// eslint-disable-next-line
rtcpeers.soup?.pauseConsumer(consumer);
},
[callObject]
);
const resumeVideoTrack = useCallback(
(id) => {
/**
* Ignore undefined, local or screenshare.
*/
if (!id || id.includes('local') || id.includes('screen')) return;
const videoTrack = callObject.participants()?.[id]?.tracks?.video;
if (!videoTrack?.subscribed) {
callObject.updateParticipant(id, {
setSubscribedTracks: true,
});
return;
}
// eslint-disable-next-line
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
return;
}
// eslint-disable-next-line
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
if (!consumer) return;
// eslint-disable-next-line
rtcpeers.soup?.resumeConsumer(consumer);
},
[callObject]
);
}, [callObject, pauseVideoTrack]);
return (
<TracksContext.Provider
value={{
audioTracks: state.audioTracks,
videoTracks: state.videoTracks,
pauseVideoTrack,
resumeVideoTrack,
videoTracks: state.videoTracks,
updateCamSubscriptions,
remoteParticipantIds,
recentSpeakerIds,
}}
>
{children}

View File

@ -8,6 +8,7 @@
* - A session id for each remote participant
* - "<id>-screen" for each shared screen
*/
import fasteq from 'fast-deep-equal';
import {
DEVICE_STATE_OFF,
DEVICE_STATE_BLOCKED,
@ -15,8 +16,9 @@ import {
} from './useDevices';
const initialParticipantsState = {
participants: {
local: {
lastPendingUnknownActiveSpeaker: null,
participants: [
{
camMutedByHost: false,
hasNameSet: false,
id: 'local',
@ -31,9 +33,9 @@ const initialParticipantsState = {
lastActiveDate: null,
micMutedByHost: false,
name: '',
position: 1,
},
},
],
screens: [],
};
// --- Derived data ---
@ -46,45 +48,17 @@ function getScreenId(id) {
return `${id}-screen`;
}
function isLocalId(id) {
return typeof id === 'string' && id === 'local';
}
function isScreenId(id) {
return typeof id === 'string' && id.endsWith('-screen');
}
// ---Helpers ---
function getMaxPosition(participants) {
return Math.max(
1,
Math.max(...Object.values(participants).map(({ position }) => position))
);
}
function getUpdatedParticipant(participant, participants) {
const id = getId(participant);
const prevItem = participants[id];
const { local } = participant;
const { audio, video } = participant.tracks;
return {
...prevItem,
camMutedByHost: video?.off?.byRemoteRequest,
hasNameSet: !!participant.user_name,
id,
isCamMuted:
video?.state === DEVICE_STATE_OFF ||
video?.state === DEVICE_STATE_BLOCKED,
isLoading:
audio?.state === DEVICE_STATE_LOADING ||
video?.state === DEVICE_STATE_LOADING,
isLocal: local,
isMicMuted:
audio?.state === DEVICE_STATE_OFF ||
audio?.state === DEVICE_STATE_BLOCKED,
isOwner: !!participant.owner,
isRecording: !!participant.record,
micMutedByHost: audio?.off?.byRemoteRequest,
name: participant.user_name,
};
}
function getNewParticipant(participant, participants) {
function getNewParticipant(participant) {
const id = getId(participant);
const { local } = participant;
@ -111,11 +85,41 @@ function getNewParticipant(participant, participants) {
lastActiveDate: null,
micMutedByHost: audio?.off?.byRemoteRequest,
name: participant.user_name,
position: local ? 0 : getMaxPosition(participants) + 1,
};
}
function getScreenItem(participant, participants) {
function getUpdatedParticipant(participant, participants) {
const id = getId(participant);
const prevItem = participants.find((p) => p.id === id);
// In case we haven't set up this participant, yet.
if (!prevItem) return getNewParticipant(participant);
const { local } = participant;
const { audio, video } = participant.tracks;
return {
...prevItem,
camMutedByHost: video?.off?.byRemoteRequest,
hasNameSet: !!participant.user_name,
id,
isCamMuted:
video?.state === DEVICE_STATE_OFF ||
video?.state === DEVICE_STATE_BLOCKED,
isLoading:
audio?.state === DEVICE_STATE_LOADING ||
video?.state === DEVICE_STATE_LOADING,
isLocal: local,
isMicMuted:
audio?.state === DEVICE_STATE_OFF ||
audio?.state === DEVICE_STATE_BLOCKED,
isOwner: !!participant.owner,
isRecording: !!participant.record,
micMutedByHost: audio?.off?.byRemoteRequest,
name: participant.user_name,
};
}
function getScreenItem(participant) {
const id = getId(participant);
return {
hasNameSet: null,
@ -125,7 +129,6 @@ function getScreenItem(participant, participants) {
isScreenshare: true,
lastActiveDate: null,
name: participant.user_name,
position: getMaxPosition(participants) + 1,
};
}
@ -143,49 +146,66 @@ function participantsReducer(prevState, action) {
switch (action.type) {
case ACTIVE_SPEAKER: {
const { participants, ...state } = prevState;
if (!action.id) return prevState;
if (!action.id)
return {
...prevState,
lastPendingUnknownActiveSpeaker: null,
};
const date = new Date();
const isParticipantKnown = participants.some((p) => p.id === action.id);
return {
...state,
participants: Object.keys(participants).reduce(
(items, id) => ({
...items,
[id]: {
...participants[id],
isActiveSpeaker: id === action.id,
lastActiveDate:
id === action.id
? new Date()
: participants[id]?.lastActiveDate,
lastPendingUnknownActiveSpeaker: isParticipantKnown
? null
: {
date,
id: action.id,
},
}),
{}
),
participants: participants.map((p) => ({
...p,
isActiveSpeaker: p.id === action.id,
lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
})),
};
}
case PARTICIPANT_JOINED: {
const item = getNewParticipant(
action.participant,
prevState.participants
);
const { id } = item;
const screenId = getScreenId(id);
const item = getNewParticipant(action.participant);
const newParticipants = {
...prevState.participants,
[id]: item,
};
const participants = [...prevState.participants];
const screens = [...prevState.screens];
const isPendingActiveSpeaker =
item.id === prevState.lastPendingUnknownActiveSpeaker?.id;
if (isPendingActiveSpeaker) {
item.isActiveSpeaker = true;
item.lastActiveDate = prevState.lastPendingUnknownActiveSpeaker?.date;
}
if (item.isCamMuted) {
participants.push(item);
} else {
const firstInactiveCamOffIndex = prevState.participants.findIndex(
(p) => p.isCamMuted && !p.isLocal && !p.isActiveSpeaker
);
if (firstInactiveCamOffIndex >= 0) {
participants.splice(firstInactiveCamOffIndex, 0, item);
} else {
participants.push(item);
}
}
// Participant is sharing screen
if (action.participant.screen) {
newParticipants[screenId] = getScreenItem(
action.participant,
newParticipants
);
screens.push(getScreenItem(action.participant));
}
return {
...prevState,
participants: newParticipants,
lastPendingUnknownActiveSpeaker: isPendingActiveSpeaker
? null
: prevState.lastPendingUnknownActiveSpeaker,
participants,
screens,
};
}
case PARTICIPANT_UPDATED: {
@ -196,60 +216,58 @@ function participantsReducer(prevState, action) {
const { id } = item;
const screenId = getScreenId(id);
const newParticipants = {
...prevState.participants,
};
newParticipants[id] = item;
const participants = [...prevState.participants];
const idx = participants.findIndex((p) => p.id === id);
participants[idx] = item;
const screens = [...prevState.screens];
const screenIdx = screens.findIndex((s) => s.id === screenId);
if (action.participant.screen) {
newParticipants[screenId] = getScreenItem(
action.participant,
newParticipants
);
} else {
delete newParticipants[screenId];
const screenItem = getScreenItem(action.participant);
if (screenIdx >= 0) {
screens[screenIdx] = screenItem;
} else {
screens.push(screenItem);
}
} else if (screenIdx >= 0) {
screens.splice(screenIdx, 1);
}
return {
const newState = {
...prevState,
participants: newParticipants,
participants,
screens,
};
if (fasteq(newState, prevState)) {
return prevState;
}
return newState;
}
case PARTICIPANT_LEFT: {
const id = getId(action.participant);
const screenId = getScreenId(id);
const { ...participants } = prevState.participants;
delete participants[id];
delete participants[screenId];
return {
...prevState,
participants,
participants: [...prevState.participants].filter((p) => p.id !== id),
screens: [...prevState.screens].filter((s) => s.id !== screenId),
};
}
case SWAP_POSITION: {
const { participants, ...state } = prevState;
const participants = [...prevState.participants];
if (!action.id1 || !action.id2) return prevState;
const pos1 = participants[action.id1]?.position;
const pos2 = participants[action.id2]?.position;
if (!pos1 || !pos2) return prevState;
const idx1 = participants.findIndex((p) => p.id === action.id1);
const idx2 = participants.findIndex((p) => p.id === action.id2);
if (idx1 === -1 || idx2 === -1) return prevState;
const tmp = participants[idx1];
participants[idx1] = participants[idx2];
participants[idx2] = tmp;
return {
...state,
participants: Object.keys(participants).reduce((items, id) => {
let { position } = participants[id];
if (action.id1 === id) {
position = pos2;
}
if (action.id2 === id) {
position = pos1;
}
return {
...items,
[id]: {
...participants[id],
position,
},
};
}, {}),
...prevState,
participants,
};
}
default:
@ -261,10 +279,12 @@ export {
ACTIVE_SPEAKER,
getId,
getScreenId,
isLocalId,
isScreenId,
participantsReducer,
initialParticipantsState,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
participantsReducer,
SWAP_POSITION,
};

View File

@ -1,3 +1,11 @@
/**
* Track state & reducer
* ---
* All (participant & screen) video and audio tracks indexed on participant ID
* If using manual track subscriptions, we'll also keep a record of those
* and their playing / paused state
*/
import { getId, getScreenId } from './participantsState';
const initialTracksState = {
@ -10,19 +18,20 @@ const initialTracksState = {
const TRACK_STARTED = 'TRACK_STARTED';
const TRACK_STOPPED = 'TRACK_STOPPED';
const REMOVE_TRACKS = 'REMOVE_TRACKS';
const UPDATE_TRACKS = 'UPDATE_TRACKS';
// --- Reducer and helpers --
function tracksReducer(prevState, action) {
switch (action.type) {
case TRACK_STARTED:
case TRACK_STOPPED: {
const id = action.participant ? getId(action.participant) : null;
const screenId = action.participant ? getScreenId(id) : null;
case TRACK_STARTED: {
const id = getId(action.participant);
const screenId = getScreenId(id);
if (action.track.kind === 'audio' && !action.participant?.local) {
// Ignore local audio from mic and screen share
if (action.track.kind === 'audio') {
if (action.participant?.local) {
// Ignore local audio from mic and screen share
return prevState;
}
const newAudioTracks = {
[id]: action.participant.tracks.audio,
};
@ -52,6 +61,39 @@ function tracksReducer(prevState, action) {
},
};
}
case TRACK_STOPPED: {
const { audioTracks, videoTracks } = prevState;
const newAudioTracks = { ...audioTracks };
const newVideoTracks = { ...videoTracks };
action.items.forEach(([participant, track]) => {
const id = participant ? getId(participant) : null;
const screenId = participant ? getScreenId(id) : null;
if (track.kind === 'audio') {
if (!participant?.local) {
// Ignore local audio from mic and screen share
newAudioTracks[id] = participant.tracks.audio;
if (participant.screen) {
newAudioTracks[screenId] = participant.tracks.screenAudio;
}
}
} else if (track.kind === 'video') {
newVideoTracks[id] = participant.tracks.video;
if (participant.screen) {
newVideoTracks[screenId] = participant.tracks.screenVideo;
}
}
});
return {
audioTracks: newAudioTracks,
videoTracks: newVideoTracks,
};
}
case REMOVE_TRACKS: {
const { audioTracks, videoTracks } = prevState;
const id = getId(action.participant);
@ -67,36 +109,7 @@ function tracksReducer(prevState, action) {
videoTracks,
};
}
case UPDATE_TRACKS: {
const { audioTracks, videoTracks } = prevState;
const id = getId(action.participant);
const screenId = getScreenId(id);
const newAudioTracks = {
...audioTracks,
};
const newVideoTracks = {
...videoTracks,
[id]: action.participant.tracks.video,
};
if (!action.participant.local) {
newAudioTracks[id] = action.participant.tracks.audio;
}
if (action.participant.screen) {
newVideoTracks[screenId] = action.participant.tracks.screenVideo;
if (!action.participant.local) {
newAudioTracks[screenId] = action.participant.tracks.screenAudio;
}
} else {
delete newAudioTracks[screenId];
delete newVideoTracks[screenId];
}
return {
audioTracks: newAudioTracks,
videoTracks: newVideoTracks,
};
}
default:
throw new Error();
}
@ -104,9 +117,8 @@ function tracksReducer(prevState, action) {
export {
initialTracksState,
tracksReducer,
REMOVE_TRACKS,
TRACK_STARTED,
TRACK_STOPPED,
UPDATE_TRACKS,
tracksReducer,
};

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;

View File

@ -0,0 +1,24 @@
import { useDeepCompareEffect } from 'use-deep-compare';
import { useTracks } from '../contexts/TracksProvider';
/**
* Updates cam subscriptions based on passed ids and pausedIds.
* @param ids Participant ids which should be subscribed to.
* @param pausedIds Participant ids which should be subscribed, but paused.
* @param delay Throttle in milliseconds. Default: 50
*/
export const useCamSubscriptions = (ids, pausedIds = [], throttle = 50) => {
const { updateCamSubscriptions } = useTracks();
useDeepCompareEffect(() => {
if (!ids || !pausedIds) return false;
const timeout = setTimeout(() => {
updateCamSubscriptions(ids, pausedIds);
}, throttle);
return () => {
clearTimeout(timeout);
};
}, [ids, pausedIds, throttle, updateCamSubscriptions]);
};
export default useCamSubscriptions;

View File

@ -0,0 +1,43 @@
/* global rtcpeers */
import { useEffect } from 'react';
/**
* This hook will switch between one of the 3 simulcast layers
* depending on the number of participants present on the call
* to optimise bandwidth / cpu usage
*
* Note: the API for this feature is currently work in progress
* and not documented. Momentarily we are using an internal
* method `setPreferredLayerForTrack` found on the global
* `rtcpeers` object.
*
* Note: this will have no effect when not in SFU mode
*/
export const usePreferredLayer = (participants) => {
/**
* Set bandwidth layer based on amount of visible participants
*/
useEffect(() => {
if (typeof rtcpeers === 'undefined' || rtcpeers?.getCurrentType() !== 'sfu')
return;
const sfu = rtcpeers.soup;
const count = participants.length;
participants.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);
}
});
}, [participants]);
};
export default usePreferredLayer;

View File

@ -15,7 +15,7 @@ export const useVideoTrack = (participant) => {
!participant.isScreenshare)
)
return null;
return videoTrack?.track;
return videoTrack?.persistentTrack;
}, [participant?.id, videoTracks]);
};

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.00024 23.414L17.7072 12.707C18.0982 12.316 18.0982 11.684 17.7072 11.293L7.00024 0.586L5.58624 2L15.5862 12L5.58624 22L7.00024 23.414Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -5,6 +5,8 @@ export default function getDemoProps() {
isConfigured: !!process.env.DAILY_DOMAIN && !!process.env.DAILY_API_KEY,
// Have we predefined a room to use?
predefinedRoom: process.env.DAILY_ROOM || '',
// Manual or automatic track subscriptions
subscribeToTracksAutomatically: !process.env.MANUAL_TRACK_SUBS,
// Are we running in demo mode? (automatically creates a short-expiry room)
demoMode: !!process.env.DAILY_DEMO_MODE,
};

View File

@ -8,6 +8,7 @@
"bowser": "^2.11.0",
"classnames": "^2.3.1",
"debounce": "^1.2.1",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.1.23",
"no-scroll": "^2.1.1",
"prop-types": "^15.7.2",

View File

@ -160,10 +160,17 @@
"@babel/helper-validator-identifier" "^7.12.11"
to-fast-properties "^2.0.0"
<<<<<<< HEAD
"@daily-co/daily-js@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.14.0.tgz#29308c77e00886514df7d932d771980d5cdb7618"
integrity sha512-OD2epVohYraTfOH/ZuO5rP9Ej4Rfu/ufGXX0XJQG+mAu1hJ1610JWunnszTmfhk+uUH4aA9i7+5/PQ2meOXUtQ==
=======
"@daily-co/daily-js@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.15.0.tgz#9dfd5c3ed8855df31c370d5b21a3b5098cce3c4f"
integrity sha512-rnivho7yx/yEOtqL81L4daPy9C/FDXf06k06df8vmyUXsE8y+cxSTD7ZvYIJDGJHN6IZRhVxxfbCyPI8CHfwCg==
>>>>>>> 1068a9f75322d380546319034f8c236029567085
dependencies:
"@babel/runtime" "^7.12.5"
bowser "^2.8.1"
@ -1532,7 +1539,7 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
md5.js "^1.3.4"
safe-buffer "^5.1.1"
fast-deep-equal@^3.1.1:
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==