{customCapsule && (
diff --git a/dailyjs/basic-call/components/Room/RoomContainer.js b/dailyjs/basic-call/components/Room/RoomContainer.js
index da0071f..e3e674d 100644
--- a/dailyjs/basic-call/components/Room/RoomContainer.js
+++ b/dailyjs/basic-call/components/Room/RoomContainer.js
@@ -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 && }
-
{/* Tray buttons */}
-
{/* Audio tags */}
>
@@ -59,4 +57,4 @@ RoomContainer.propTypes = {
children: PropTypes.node,
};
-export default React.memo(RoomContainer, () => true);
+export default RoomContainer;
diff --git a/dailyjs/basic-call/components/Room/index.js b/dailyjs/basic-call/components/Room/index.js
index ebab667..2dc7fcb 100644
--- a/dailyjs/basic-call/components/Room/index.js
+++ b/dailyjs/basic-call/components/Room/index.js
@@ -1 +1,2 @@
export { Room as default } from './Room';
+export { RoomContainer } from './RoomContainer';
diff --git a/dailyjs/basic-call/components/VideoGrid/VideoGrid.js b/dailyjs/basic-call/components/VideoGrid/VideoGrid.js
index 223b424..744f185 100644
--- a/dailyjs/basic-call/components/VideoGrid/VideoGrid.js
+++ b/dailyjs/basic-call/components/VideoGrid/VideoGrid.js
@@ -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) => (
)),
- [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;
}
diff --git a/dailyjs/basic-call/pages/index.js b/dailyjs/basic-call/pages/index.js
index 7c0df78..d2c213b 100644
--- a/dailyjs/basic-call/pages/index.js
+++ b/dailyjs/basic-call/pages/index.js
@@ -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}
>
-
+
@@ -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,
};
diff --git a/dailyjs/live-fitness/components/App/App.js b/dailyjs/live-fitness/components/App/App.js
index 8c3b08a..21ba2ae 100644
--- a/dailyjs/live-fitness/components/App/App.js
+++ b/dailyjs/live-fitness/components/App/App.js
@@ -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 = () => (
<>
-
+ ,
+ }}
+ />
>
);
diff --git a/dailyjs/live-fitness/components/Room/Room.js b/dailyjs/live-fitness/components/Room/Room.js
new file mode 100644
index 0000000..8d2a1e8
--- /dev/null
+++ b/dailyjs/live-fitness/components/Room/Room.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import RoomContainer from '@dailyjs/basic-call/components/Room/RoomContainer';
+
+export const Room = () => (
+
+ Hello
+
+);
+
+export default RoomContainer;
diff --git a/dailyjs/live-fitness/components/Room/index.js b/dailyjs/live-fitness/components/Room/index.js
new file mode 100644
index 0000000..ebab667
--- /dev/null
+++ b/dailyjs/live-fitness/components/Room/index.js
@@ -0,0 +1 @@
+export { Room as default } from './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
new file mode 100644
index 0000000..733e3b7
--- /dev/null
+++ b/dailyjs/pagination/README.md
@@ -0,0 +1,47 @@
+# Pagination, Sorting & Track Management
+
+
+
+### 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
+
+[](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)
diff --git a/dailyjs/pagination/components/App/App.js b/dailyjs/pagination/components/App/App.js
new file mode 100644
index 0000000..317d450
--- /dev/null
+++ b/dailyjs/pagination/components/App/App.js
@@ -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 = () => (
+ ,
+ }}
+ />
+);
+
+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..94b7642
--- /dev/null
+++ b/dailyjs/pagination/components/PaginatedVideoGrid/PaginatedVideoGrid.js
@@ -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) => (
+
+ )),
+ [
+ activeParticipant,
+ participantCount,
+ tileWidth,
+ tileHeight,
+ visibleParticipants,
+ ]
+ );
+
+ const handlePrevClick = () => setPage((p) => p - 1);
+ const handleNextClick = () => setPage((p) => p + 1);
+
+ return (
+
+
+
{tiles}
+
+
+
+ );
+};
+
+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/components/Tray/Tray.js b/dailyjs/pagination/components/Tray/Tray.js
new file mode 100644
index 0000000..e04e896
--- /dev/null
+++ b/dailyjs/pagination/components/Tray/Tray.js
@@ -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 (
+ <>
+ {
+ callObject.addFakeParticipant();
+ }}
+ >
+
+
+ >
+ );
+};
+
+export default Tray;
diff --git a/dailyjs/pagination/components/Tray/index.js b/dailyjs/pagination/components/Tray/index.js
new file mode 100644
index 0000000..100bcc8
--- /dev/null
+++ b/dailyjs/pagination/components/Tray/index.js
@@ -0,0 +1 @@
+export { Tray as default } from './Tray';
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/image.png b/dailyjs/pagination/image.png
new file mode 100644
index 0000000..bbcb174
Binary files /dev/null and b/dailyjs/pagination/image.png differ
diff --git a/dailyjs/pagination/next.config.js b/dailyjs/pagination/next.config.js
new file mode 100644
index 0000000..9a0a6ee
--- /dev/null
+++ b/dailyjs/pagination/next.config.js
@@ -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,
+ },
+});
diff --git a/dailyjs/pagination/package.json b/dailyjs/pagination/package.json
new file mode 100644
index 0000000..796128e
--- /dev/null
+++ b/dailyjs/pagination/package.json
@@ -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"
+ }
+}
diff --git a/dailyjs/pagination/pages/_app.js b/dailyjs/pagination/pages/_app.js
new file mode 100644
index 0000000..5ffe4b9
--- /dev/null
+++ b/dailyjs/pagination/pages/_app.js
@@ -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 = ;
+App.customAppComponent = ;
+
+export default App;
diff --git a/dailyjs/pagination/pages/api b/dailyjs/pagination/pages/api
new file mode 120000
index 0000000..999f604
--- /dev/null
+++ b/dailyjs/pagination/pages/api
@@ -0,0 +1 @@
+../../basic-call/pages/api
\ No newline at end of file
diff --git a/dailyjs/pagination/pages/index.js b/dailyjs/pagination/pages/index.js
new file mode 100644
index 0000000..2668138
--- /dev/null
+++ b/dailyjs/pagination/pages/index.js
@@ -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;
diff --git a/dailyjs/pagination/public b/dailyjs/pagination/public
new file mode 120000
index 0000000..33a6e67
--- /dev/null
+++ b/dailyjs/pagination/public
@@ -0,0 +1 @@
+../basic-call/public
\ No newline at end of file
diff --git a/dailyjs/shared/components/DeviceSelect/DeviceSelect.js b/dailyjs/shared/components/DeviceSelect/DeviceSelect.js
index 3fba799..c089280 100644
--- a/dailyjs/shared/components/DeviceSelect/DeviceSelect.js
+++ b/dailyjs/shared/components/DeviceSelect/DeviceSelect.js
@@ -18,8 +18,6 @@ export const DeviceSelect = () => {
return
Loading devices...
;
}
- console.log(currentDevices);
-
return (
<>
diff --git a/dailyjs/shared/components/HairCheck/HairCheck.js b/dailyjs/shared/components/HairCheck/HairCheck.js
index 659696c..f6f1e15 100644
--- a/dailyjs/shared/components/HairCheck/HairCheck.js
+++ b/dailyjs/shared/components/HairCheck/HairCheck.js
@@ -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();
diff --git a/dailyjs/shared/contexts/CallProvider.js b/dailyjs/shared/contexts/CallProvider.js
index 15c6f31..848069b 100644
--- a/dailyjs/shared/contexts/CallProvider.js
+++ b/dailyjs/shared/contexts/CallProvider.js
@@ -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);
diff --git a/dailyjs/shared/contexts/ParticipantsProvider.js b/dailyjs/shared/contexts/ParticipantsProvider.js
index 8fc28c5..6fc344c 100644
--- a/dailyjs/shared/contexts/ParticipantsProvider.js
+++ b/dailyjs/shared/contexts/ParticipantsProvider.js
@@ -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,
diff --git a/dailyjs/shared/contexts/TracksProvider.js b/dailyjs/shared/contexts/TracksProvider.js
index f67986c..d1c137e 100644
--- a/dailyjs/shared/contexts/TracksProvider.js
+++ b/dailyjs/shared/contexts/TracksProvider.js
@@ -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 (
{children}
diff --git a/dailyjs/shared/contexts/participantsState.js b/dailyjs/shared/contexts/participantsState.js
index cffab31..a447724 100644
--- a/dailyjs/shared/contexts/participantsState.js
+++ b/dailyjs/shared/contexts/participantsState.js
@@ -8,6 +8,7 @@
* - A session id for each remote participant
* - "-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,
};
diff --git a/dailyjs/shared/contexts/tracksState.js b/dailyjs/shared/contexts/tracksState.js
index 36d5966..a168fc5 100644
--- a/dailyjs/shared/contexts/tracksState.js
+++ b/dailyjs/shared/contexts/tracksState.js
@@ -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,
};
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;
diff --git a/dailyjs/shared/hooks/useCamSubscriptions.js b/dailyjs/shared/hooks/useCamSubscriptions.js
new file mode 100644
index 0000000..d767710
--- /dev/null
+++ b/dailyjs/shared/hooks/useCamSubscriptions.js
@@ -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;
diff --git a/dailyjs/shared/hooks/usePreferredLayer.js b/dailyjs/shared/hooks/usePreferredLayer.js
new file mode 100644
index 0000000..3da474d
--- /dev/null
+++ b/dailyjs/shared/hooks/usePreferredLayer.js
@@ -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;
diff --git a/dailyjs/shared/hooks/useVideoTrack.js b/dailyjs/shared/hooks/useVideoTrack.js
index 3b7a44f..0b2129f 100644
--- a/dailyjs/shared/hooks/useVideoTrack.js
+++ b/dailyjs/shared/hooks/useVideoTrack.js
@@ -15,7 +15,7 @@ export const useVideoTrack = (participant) => {
!participant.isScreenshare)
)
return null;
- return videoTrack?.track;
+ return videoTrack?.persistentTrack;
}, [participant?.id, videoTracks]);
};
diff --git a/dailyjs/shared/icons/raquo-md.svg b/dailyjs/shared/icons/raquo-md.svg
new file mode 100644
index 0000000..6574767
--- /dev/null
+++ b/dailyjs/shared/icons/raquo-md.svg
@@ -0,0 +1,3 @@
+
diff --git a/dailyjs/shared/lib/demoProps.js b/dailyjs/shared/lib/demoProps.js
index 8aea1f6..ba814bb 100644
--- a/dailyjs/shared/lib/demoProps.js
+++ b/dailyjs/shared/lib/demoProps.js
@@ -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,
};
diff --git a/dailyjs/shared/package.json b/dailyjs/shared/package.json
index ed9c59a..c8383c3 100644
--- a/dailyjs/shared/package.json
+++ b/dailyjs/shared/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 61dd260..cbf8486 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==