diff --git a/custom/basic-call/components/Call/Container.js b/custom/basic-call/components/Call/Container.js
index 22e0d08..5e9cb68 100644
--- a/custom/basic-call/components/Call/Container.js
+++ b/custom/basic-call/components/Call/Container.js
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { Audio } from '@custom/shared/components/Audio';
import { BasicTray } from '@custom/shared/components/Tray';
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
-import useJoinSound from '@custom/shared/hooks/useJoinSound';
+import { useJoinSound } from '@custom/shared/hooks/useJoinSound';
import PropTypes from 'prop-types';
import { WaitingRoom } from './WaitingRoom';
diff --git a/custom/fitness-demo/.babelrc b/custom/fitness-demo/.babelrc
new file mode 100644
index 0000000..a6f4434
--- /dev/null
+++ b/custom/fitness-demo/.babelrc
@@ -0,0 +1,4 @@
+{
+ "presets": ["next/babel"],
+ "plugins": ["inline-react-svg"]
+}
diff --git a/custom/fitness-demo/.gitignore b/custom/fitness-demo/.gitignore
new file mode 100644
index 0000000..058f0ec
--- /dev/null
+++ b/custom/fitness-demo/.gitignore
@@ -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
\ No newline at end of file
diff --git a/custom/fitness-demo/README.md b/custom/fitness-demo/README.md
new file mode 100644
index 0000000..1bec2c5
--- /dev/null
+++ b/custom/fitness-demo/README.md
@@ -0,0 +1,55 @@
+# Fitness Demo
+
+
+
+### Live example
+
+**[See it in action here β‘οΈ](https://custom-fitness-demo.vercel.app)**
+
+---
+
+## What does this demo do?
+
+- Built on [NextJS](https://nextjs.org/)
+- Create a Daily instance using call object mode
+- Manage user media devices
+- Render UI based on the call state
+- Handle media and call errors
+- Obtain call access token via Daily REST API
+- Handle preauthentication, knock for access and auto join
+
+Please note: this demo is not currently mobile optimised
+
+### Getting started
+
+```
+# set both DAILY_API_KEY and DAILY_DOMAIN
+mv env.example .env.local
+
+# from project root...
+yarn
+yarn workspace @custom/fitness-demo dev
+```
+
+## How does this example work?
+
+This demo puts to work the following [shared libraries](../shared):
+
+**[MediaDeviceProvider.js](../shared/contexts/MediaDeviceProvider.js)**
+Convenience context that provides an interface to media devices throughout app
+
+**[useDevices.js](../shared/contexts/useDevices.js)**
+Hook for managing the enumeration and status of client media devices)
+
+**[CallProvider.js](../shared/contexts/CallProvider.js)**
+Primary call context that manages Daily call state, participant state and call object interaction
+
+**[useCallMachine.js](../shared/contexts/useCallMachine.js)**
+Abstraction hook that manages Daily call state and error handling
+
+**[ParticipantsProvider.js](../shared/contexts/ParticipantsProvider.js)**
+Manages participant state and abstracts common selectors / derived data
+
+## 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/custom/fitness-demo/components/App/App.js b/custom/fitness-demo/components/App/App.js
new file mode 100644
index 0000000..a86b453
--- /dev/null
+++ b/custom/fitness-demo/components/App/App.js
@@ -0,0 +1,63 @@
+import React, { useMemo } from 'react';
+import { LiveStreamingProvider } from '@custom/live-streaming/contexts/LiveStreamingProvider';
+import { RecordingProvider } from '@custom/recording/contexts/RecordingProvider';
+import ExpiryTimer from '@custom/shared/components/ExpiryTimer';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useCallUI } from '@custom/shared/hooks/useCallUI';
+import PropTypes from 'prop-types';
+
+import { ChatProvider } from '../../contexts/ChatProvider';
+import { ClassStateProvider } from '../../contexts/ClassStateProvider';
+import Room from '../Call/Room';
+import { Asides } from './Asides';
+import { Modals } from './Modals';
+
+export const App = ({ customComponentForState }) => {
+ const { roomExp, state } = useCallState();
+
+ const componentForState = useCallUI({
+ state,
+ room: ,
+ ...customComponentForState,
+ });
+
+ // Memoize children to avoid unnecassary renders from HOC
+ return useMemo(
+ () => (
+ <>
+
+
+
+
+ {roomExp && }
+
+ {componentForState()}
+
+
+
+
+
+
+
+
+ >
+ ),
+ [componentForState, roomExp]
+ );
+};
+
+App.propTypes = {
+ customComponentForState: PropTypes.any,
+};
+
+export default App;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/App/AsideHeader.js b/custom/fitness-demo/components/App/AsideHeader.js
new file mode 100644
index 0000000..ca354dc
--- /dev/null
+++ b/custom/fitness-demo/components/App/AsideHeader.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import { PEOPLE_ASIDE } from '@custom/shared/components/Aside/PeopleAside';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { CHAT_ASIDE } from '../Call/ChatAside';
+
+export const AsideHeader = () => {
+ const { showAside, setShowAside } = useUIState();
+
+ return (
+ <>
+
+
setShowAside(PEOPLE_ASIDE)}
+ >
+
People
+
+
setShowAside(CHAT_ASIDE)}
+ >
+
Chat
+
+
+
+ >
+ )
+};
+
+export default AsideHeader;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/App/Asides.js b/custom/fitness-demo/components/App/Asides.js
new file mode 100644
index 0000000..855bc0e
--- /dev/null
+++ b/custom/fitness-demo/components/App/Asides.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import { NetworkAside } from '@custom/shared/components/Aside';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { PeopleAside } from '../Call/PeopleAside';
+
+export const Asides = () => {
+ const { asides } = useUIState();
+
+ return (
+ <>
+
+
+ {asides.map((AsideComponent) => (
+
+ ))}
+ >
+ );
+};
+
+export default Asides;
diff --git a/custom/fitness-demo/components/App/Modals.js b/custom/fitness-demo/components/App/Modals.js
new file mode 100644
index 0000000..ba5c27c
--- /dev/null
+++ b/custom/fitness-demo/components/App/Modals.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import DeviceSelectModal from '@custom/shared/components/DeviceSelectModal/DeviceSelectModal';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+
+export const Modals = () => {
+ const { modals } = useUIState();
+
+ return (
+ <>
+
+ {modals.map((ModalComponent) => (
+
+ ))}
+ >
+ );
+};
+
+export default Modals;
diff --git a/custom/fitness-demo/components/App/index.js b/custom/fitness-demo/components/App/index.js
new file mode 100644
index 0000000..7e7372b
--- /dev/null
+++ b/custom/fitness-demo/components/App/index.js
@@ -0,0 +1 @@
+export { App as default } from './App';
diff --git a/custom/fitness-demo/components/Call/ChatAside.js b/custom/fitness-demo/components/Call/ChatAside.js
new file mode 100644
index 0000000..d2caa5f
--- /dev/null
+++ b/custom/fitness-demo/components/Call/ChatAside.js
@@ -0,0 +1,172 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Aside } from '@custom/shared/components/Aside';
+import Button from '@custom/shared/components/Button';
+import { TextInput } from '@custom/shared/components/Input';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconEmoji } from '@custom/shared/icons/emoji-sm.svg';
+import { useMessageSound } from '@custom/text-chat/hooks/useMessageSound';
+import { useChat } from '../../contexts/ChatProvider';
+import AsideHeader from '../App/AsideHeader';
+
+export const CHAT_ASIDE = 'chat';
+
+export const ChatAside = () => {
+ const { showAside, setShowAside } = useUIState();
+ const { sendMessage, chatHistory, hasNewMessages, setHasNewMessages } =
+ useChat();
+ const [newMessage, setNewMessage] = useState('');
+ const playMessageSound = useMessageSound();
+ const [showEmojis, setShowEmojis] = useState(false);
+
+ const emojis = ['π', 'π', 'π', 'π', 'π'];
+ const chatWindowRef = useRef();
+
+ useEffect(() => {
+ // Clear out any new message notifications if we're showing the chat screen
+ if (showAside === CHAT_ASIDE) {
+ setHasNewMessages(false);
+ }
+ }, [showAside, chatHistory?.length, setHasNewMessages]);
+
+ useEffect(() => {
+ if (hasNewMessages && showAside !== CHAT_ASIDE) {
+ playMessageSound();
+ }
+ }, [playMessageSound, showAside, hasNewMessages]);
+
+ useEffect(() => {
+ if (chatWindowRef.current) {
+ chatWindowRef.current.scrollTop = chatWindowRef.current.scrollHeight;
+ }
+ }, [chatHistory?.length]);
+
+ if (!showAside || showAside !== CHAT_ASIDE) {
+ return null;
+ }
+
+ return (
+ setShowAside(false)}>
+
+
+ {chatHistory?.map((chatItem) => (
+
+ {chatItem.message}
+ {chatItem.sender}
+
+ ))}
+
+ {showEmojis && (
+
+ {emojis.map(emoji => (
+ sendMessage(emoji)}
+ >
+ {emoji}
+
+ ))}
+
+ )}
+
+ setShowEmojis(!showEmojis)}
+ >
+
+
+ setNewMessage(e.target.value)}
+ />
+ {
+ sendMessage(newMessage);
+ setNewMessage('');
+ }}
+ >
+ Send
+
+
+
+
+ );
+};
+
+export default ChatAside;
diff --git a/custom/fitness-demo/components/Call/Container.js b/custom/fitness-demo/components/Call/Container.js
new file mode 100644
index 0000000..5e9cb68
--- /dev/null
+++ b/custom/fitness-demo/components/Call/Container.js
@@ -0,0 +1,49 @@
+import React, { useMemo } from 'react';
+import { Audio } from '@custom/shared/components/Audio';
+import { BasicTray } from '@custom/shared/components/Tray';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useJoinSound } from '@custom/shared/hooks/useJoinSound';
+import PropTypes from 'prop-types';
+import { WaitingRoom } from './WaitingRoom';
+
+export const Container = ({ children }) => {
+ const { isOwner } = useParticipants();
+
+ useJoinSound();
+
+ const roomComponents = useMemo(
+ () => (
+ <>
+ {/* Show waiting room notification & modal if call owner */}
+ {isOwner && }
+ {/* Tray buttons */}
+
+ {/* Audio tags */}
+
+ >
+ ),
+ [isOwner]
+ );
+
+ return (
+
+ {children}
+ {roomComponents}
+
+
+
+ );
+};
+
+Container.propTypes = {
+ children: PropTypes.node,
+};
+
+export default Container;
diff --git a/custom/fitness-demo/components/Call/Header.js b/custom/fitness-demo/components/Call/Header.js
new file mode 100644
index 0000000..38a6d0f
--- /dev/null
+++ b/custom/fitness-demo/components/Call/Header.js
@@ -0,0 +1,96 @@
+import React, { useMemo, useCallback } from 'react';
+import Button from '@custom/shared/components/Button';
+import HeaderCapsule from '@custom/shared/components/HeaderCapsule';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconLock } from '@custom/shared/icons/lock-md.svg';
+import { ReactComponent as IconPlay } from '@custom/shared/icons/play-sm.svg';
+import { slugify } from '@custom/shared/lib/slugify';
+import { useClassState, PRE_CLASS_LOBBY, CLASS_IN_SESSION } from '../../contexts/ClassStateProvider';
+
+export const Header = () => {
+ const { roomInfo } = useCallState();
+ const { participantCount, localParticipant } = useParticipants();
+ const { customCapsule } = useUIState();
+ const { classType, setClassType } = useClassState();
+
+ const capsuleLabel = useCallback(() => {
+ if (!localParticipant.isOwner) return;
+ if (classType === PRE_CLASS_LOBBY)
+ return (
+
+ Start Class
+
+ )
+ if (classType === CLASS_IN_SESSION)
+ return (
+
+ End Class
+
+ )
+ }, [classType, localParticipant.isOwner, setClassType]);
+
+ return useMemo(
+ () => (
+
+
+
+
+ {roomInfo.privacy === 'private' && }
+ {slugify.revert(roomInfo.name)}
+
+
+ {`${participantCount} ${
+ participantCount === 1 ? 'participant' : 'participants'
+ }`}
+
+
+ {classType}
+ {capsuleLabel()}
+
+ {customCapsule && (
+
+ {customCapsule.variant === 'recording' && }
+ {customCapsule.label}
+
+ )}
+
+
+
+ ),
+ [roomInfo.privacy, roomInfo.name, participantCount, customCapsule, classType, capsuleLabel]
+ );
+};
+
+export default Header;
diff --git a/custom/fitness-demo/components/Call/InviteOthers.js b/custom/fitness-demo/components/Call/InviteOthers.js
new file mode 100644
index 0000000..8cda2b0
--- /dev/null
+++ b/custom/fitness-demo/components/Call/InviteOthers.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import Button from '@custom/shared/components/Button';
+import { Card, CardBody, CardHeader } from '@custom/shared/components/Card';
+import { TextInput } from '@custom/shared/components/Input';
+import Tile from '@custom/shared/components/Tile';
+import VideoContainer from '@custom/shared/components/VideoContainer';
+import { DEFAULT_ASPECT_RATIO } from '@custom/shared/constants';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import Container from './Container';
+import Header from './Header';
+
+export const InviteOthers = () => {
+ const { localParticipant } = useParticipants();
+
+ return (
+
+
+
+
+
+
+ Waiting for others to join?
+
+ Copy the link and invite them to the call!
+
+
+ navigator.clipboard.writeText(window.location.href)}
+ >
+ Copy link
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default InviteOthers;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/Call/PeopleAside.js b/custom/fitness-demo/components/Call/PeopleAside.js
new file mode 100644
index 0000000..a01853e
--- /dev/null
+++ b/custom/fitness-demo/components/Call/PeopleAside.js
@@ -0,0 +1,178 @@
+import React, { useCallback } from 'react';
+import { Aside } from '@custom/shared/components/Aside';
+import Button from '@custom/shared/components/Button';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconCamOff } from '@custom/shared/icons/camera-off-sm.svg';
+import { ReactComponent as IconCamOn } from '@custom/shared/icons/camera-on-sm.svg';
+import { ReactComponent as IconMicOff } from '@custom/shared/icons/mic-off-sm.svg';
+import { ReactComponent as IconMicOn } from '@custom/shared/icons/mic-on-sm.svg';
+import PropTypes from 'prop-types';
+import AsideHeader from '../App/AsideHeader';
+
+export const PEOPLE_ASIDE = 'people';
+
+const PersonRow = ({ participant, isOwner = false }) => (
+
+
+ {participant.name} {participant.isLocal && '(You)'}
+
+
+ {!isOwner ? (
+ <>
+
+ {participant.isCamMuted ? : }
+
+
+ {participant.isMicMuted ? : }
+
+ >
+ ) : (
+ <>
+
+ {participant.isCamMuted ? : }
+
+
+ {participant.isMicMuted ? : }
+
+ >
+ )}
+
+
+
+);
+PersonRow.propTypes = {
+ participant: PropTypes.object,
+ isOwner: PropTypes.bool,
+};
+
+export const PeopleAside = () => {
+ const { callObject } = useCallState();
+ const { showAside, setShowAside } = useUIState();
+ const { participants, isOwner } = useParticipants();
+
+ const muteAll = useCallback(
+ (deviceType) => {
+ let updatedParticipantList = {};
+ // Accommodate muting mics and cameras
+ const newSetting =
+ deviceType === 'video' ? { setVideo: false } : { setAudio: false };
+ for (let id in callObject.participants()) {
+ // Do not update the local participant's device (aka the instructor)
+ if (id === 'local') continue;
+
+ updatedParticipantList[id] = newSetting;
+ }
+
+ // Update all participants at once
+ callObject.updateParticipants(updatedParticipantList);
+ },
+ [callObject]
+ );
+
+ const handleMuteAllAudio = () => muteAll('audio');
+ const handleMuteAllVideo = () => muteAll('video');
+
+ if (!showAside || showAside !== PEOPLE_ASIDE) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default PeopleAside;
diff --git a/custom/fitness-demo/components/Call/Room.js b/custom/fitness-demo/components/Call/Room.js
new file mode 100644
index 0000000..2ba2617
--- /dev/null
+++ b/custom/fitness-demo/components/Call/Room.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import { VideoView } from './VideoView';
+
+export const Room = () => ;
+
+export default Room;
diff --git a/custom/fitness-demo/components/Call/VideoView.js b/custom/fitness-demo/components/Call/VideoView.js
new file mode 100644
index 0000000..2e130ae
--- /dev/null
+++ b/custom/fitness-demo/components/Call/VideoView.js
@@ -0,0 +1,23 @@
+import React, { useEffect } from 'react';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState, VIEW_MODE_SPEAKER } from '@custom/shared/contexts/UIStateProvider';
+import { GridView } from '../GridView';
+import { SpeakerView } from '../SpeakerView';
+import InviteOthers from './InviteOthers';
+
+export const VideoView = () => {
+ const { viewMode, setIsShowingScreenshare } = useUIState();
+ const { participants, screens } = useParticipants();
+
+ useEffect(() => {
+ const hasScreens = screens.length > 0;
+ setIsShowingScreenshare(hasScreens);
+ }, [screens, setIsShowingScreenshare]);
+
+ if (!participants.length) return null;
+ if (participants.length === 1 && !screens.length > 0) return ;
+
+ return viewMode === VIEW_MODE_SPEAKER ? : ;
+};
+
+export default VideoView;
diff --git a/custom/fitness-demo/components/Call/WaitingRoom.js b/custom/fitness-demo/components/Call/WaitingRoom.js
new file mode 100644
index 0000000..4266d44
--- /dev/null
+++ b/custom/fitness-demo/components/Call/WaitingRoom.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import {
+ WaitingRoomModal,
+ WaitingRoomNotification,
+} from '@custom/shared/components/WaitingRoom';
+import { useWaitingRoom } from '@custom/shared/contexts/WaitingRoomProvider';
+
+export const WaitingRoom = () => {
+ const { setShowModal, showModal } = useWaitingRoom();
+ return (
+ <>
+
+ {showModal && setShowModal(false)} />}
+ >
+ );
+};
+
+export default WaitingRoom;
diff --git a/custom/fitness-demo/components/GridView/GridView.js b/custom/fitness-demo/components/GridView/GridView.js
new file mode 100644
index 0000000..a3be7b5
--- /dev/null
+++ b/custom/fitness-demo/components/GridView/GridView.js
@@ -0,0 +1,375 @@
+import React, {
+ useRef,
+ useCallback,
+ useMemo,
+ useEffect,
+ useState,
+} from 'react';
+import Button from '@custom/shared/components/Button';
+import Tile from '@custom/shared/components/Tile';
+import VideoContainer from '@custom/shared/components/VideoContainer';
+import {
+ DEFAULT_ASPECT_RATIO,
+ MEETING_STATE_JOINED,
+} from '@custom/shared/constants';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { isLocalId } from '@custom/shared/contexts/participantsState';
+import { useActiveSpeaker } from '@custom/shared/hooks/useActiveSpeaker';
+import { useCamSubscriptions } from '@custom/shared/hooks/useCamSubscriptions';
+import { ReactComponent as IconArrow } from '@custom/shared/icons/raquo-md.svg';
+import sortByKey from '@custom/shared/lib/sortByKey';
+import PropTypes from 'prop-types';
+import { useDeepCompareMemo } from 'use-deep-compare';
+import Container from '../Call/Container';
+import Header from '../Call/Header';
+
+// --- Constants
+
+const MIN_TILE_WIDTH = 280;
+const MAX_TILES_PER_PAGE = 12;
+
+export const GridView = ({
+ maxTilesPerPage = MAX_TILES_PER_PAGE,
+}) => {
+ const { callObject } = useCallState();
+ 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 gridRef = useRef(null);
+
+ // -- Layout / UI
+
+ // 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]);
+
+ // -- Track subscriptions
+
+ // 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
+ */
+ const camSubscriptions = useMemo(() => {
+ const maxSubs = 3 * pageSize;
+
+ // Determine participant ids to subscribe to or stage, based on page
+ let renderedOrBufferedIds = [];
+ switch (page) {
+ // First page
+ case 1:
+ renderedOrBufferedIds = participants
+ .slice(0, Math.min(maxSubs, 2 * pageSize))
+ .map((p) => p.id);
+ break;
+ // Last page
+ case Math.ceil(participants.length / pageSize):
+ renderedOrBufferedIds = 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;
+ renderedOrBufferedIds = participants.slice(min, max).map((p) => p.id);
+ }
+ break;
+ }
+
+ const subscribedIds = [];
+ const stagedIds = [];
+
+ // Decide whether to subscribe to or stage participants'
+ // track based on visibility
+ renderedOrBufferedIds.forEach((id) => {
+ if (id !== isLocalId()) {
+ if (visibleParticipants.some((vp) => vp.id === id)) {
+ subscribedIds.push(id);
+ } else {
+ stagedIds.push(id);
+ }
+ }
+ });
+
+ return {
+ subscribedIds,
+ stagedIds,
+ };
+ }, [page, pageSize, participants, visibleParticipants]);
+
+ useCamSubscriptions(
+ camSubscriptions?.subscribedIds,
+ camSubscriptions?.pausedIds
+ );
+
+ /**
+ * Set bandwidth layer based on amount of visible participants
+ */
+ useEffect(() => {
+ if (!(callObject && callObject.meetingState() === MEETING_STATE_JOINED))
+ return;
+ const count = visibleParticipants.length;
+
+ let layer;
+ if (count < 5) {
+ // highest quality layer
+ layer = 2;
+ } else if (count < 10) {
+ // mid quality layer
+ layer = 1;
+ } else {
+ // low qualtiy layer
+ layer = 0;
+ }
+
+ const receiveSettings = visibleParticipants.reduce(
+ (settings, participant) => {
+ if (isLocalId(participant.id)) return settings;
+ return { ...settings, [participant.id]: { video: { layer } } };
+ },
+ {}
+ );
+ callObject.updateReceiveSettings(receiveSettings);
+ }, [visibleParticipants, callObject]);
+
+ // -- Active speaker
+
+ /**
+ * Handle position updates based on active speaker events
+ */
+ const handleActiveSpeakerChange = useCallback(
+ (peerId) => {
+ if (!peerId) return;
+ // active participant is already visible
+ if (visibleParticipants.some(({ id }) => id === peerId)) return;
+ // ignore repositioning when viewing page > 1
+ if (page > 1) return;
+
+ /**
+ * We can now assume that
+ * a) the user is looking at page 1
+ * b) the most recent active participant is not visible on page 1
+ * c) we'll have to promote the most recent participant's position to page 1
+ *
+ * To achieve that, we'll have to
+ * - find the least recent active participant on page 1
+ * - swap least & most recent active participant's position via setParticipantPosition
+ */
+ const sortedVisibleRemoteParticipants = visibleParticipants
+ .filter(({ isLocal }) => !isLocal)
+ .sort((a, b) => sortByKey(a, b, 'lastActiveDate'));
+
+ if (!sortedVisibleRemoteParticipants.length) return;
+
+ swapParticipantPosition(sortedVisibleRemoteParticipants[0].id, peerId);
+ },
+ [page, swapParticipantPosition, visibleParticipants]
+ );
+
+ useEffect(() => {
+ if (page > 1 || !activeSpeakerId) return;
+ handleActiveSpeakerChange(activeSpeakerId);
+ }, [activeSpeakerId, handleActiveSpeakerChange, page]);
+
+ const tiles = useDeepCompareMemo(
+ () =>
+ visibleParticipants.map((p) => (
+
+ )),
+ [
+ activeParticipant,
+ participantCount,
+ tileWidth,
+ tileHeight,
+ visibleParticipants,
+ ]
+ );
+
+ const handlePrevClick = () => setPage((p) => p - 1);
+ const handleNextClick = () => setPage((p) => p + 1);
+
+ return (
+
+
+
+
+ {(pages > 1 && page > 1) && (
+
+
+
+ )}
+
{tiles}
+ {(pages > 1 && page < pages) && (
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+GridView.propTypes = {
+ maxTilesPerPage: PropTypes.number,
+};
+
+export default GridView;
diff --git a/custom/fitness-demo/components/GridView/index.js b/custom/fitness-demo/components/GridView/index.js
new file mode 100644
index 0000000..5df5398
--- /dev/null
+++ b/custom/fitness-demo/components/GridView/index.js
@@ -0,0 +1,2 @@
+export { GridView as default } from './GridView';
+export { GridView } from './GridView';
diff --git a/custom/fitness-demo/components/Prejoin/Intro.js b/custom/fitness-demo/components/Prejoin/Intro.js
new file mode 100644
index 0000000..23db576
--- /dev/null
+++ b/custom/fitness-demo/components/Prejoin/Intro.js
@@ -0,0 +1,195 @@
+import React, { useEffect, useState } from 'react';
+import Button from '@custom/shared/components/Button';
+import {
+ Card,
+ CardBody,
+ CardFooter,
+ CardHeader,
+} from '@custom/shared/components/Card';
+import Field from '@custom/shared/components/Field';
+import { TextInput, BooleanInput, SelectInput } from '@custom/shared/components/Input';
+import Well from '@custom/shared/components/Well';
+import { slugify } from '@custom/shared/lib/slugify';
+import PropTypes from 'prop-types';
+
+/**
+ * Intro
+ * ---
+ * Specify which room we would like to join
+ */
+export const Intro = ({
+ tokenError,
+ fetching,
+ error,
+ onJoin,
+}) => {
+ const [rooms, setRooms] = useState({});
+ const [duration, setDuration] = useState("30");
+ const [roomName, setRoomName] = useState();
+ const [privacy, setPrivacy] = useState(true);
+
+ const fetchRooms = async () => {
+ const res = await fetch('/api/presence', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ const resJson = await res.json();
+ setRooms(resJson);
+ }
+
+ useEffect(() => {
+ fetchRooms();
+ const i = setInterval(fetchRooms, 15000);
+ return () => clearInterval(i);
+ }, []);
+
+ return (
+
+
+
+
Join a class
+
+ {Object.keys(rooms).length === 0 && (
+
+ Looks like there's no class going on right now,
+ start with creating one!
+
+ )}
+ {Object.keys(rooms).map(room => (
+
+
+
{slugify.revert(room)}
+
+ {`${rooms[room].length} ${rooms[room].length > 1 ? 'people' : 'person'} in class`}
+
+
+
+ onJoin(slugify.convert(room), 'join')}>
+ Join Class
+
+
+
+ ))}
+
+
+
+
OR
+
+
+
+
+
+ );
+};
+
+Intro.propTypes = {
+ room: PropTypes.string,
+ onJoin: PropTypes.func.isRequired,
+};
+
+export default Intro;
diff --git a/custom/fitness-demo/components/Prejoin/NotConfigured.js b/custom/fitness-demo/components/Prejoin/NotConfigured.js
new file mode 100644
index 0000000..7c379ed
--- /dev/null
+++ b/custom/fitness-demo/components/Prejoin/NotConfigured.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Card, CardBody, CardHeader } from '@custom/shared/components/Card';
+
+export const NotConfigured = () => (
+
+ Environmental variables not set
+
+
+ Please ensure you have set both the DAILY_API_KEY and{' '}
+ DAILY_DOMAIN environmental variables. An example can be
+ found in the provided env.example file.
+
+
+ If you do not yet have a Daily developer account, please{' '}
+
+ create one now
+
+ . You can find your Daily API key on the{' '}
+
+ developer page
+ {' '}
+ of the dashboard.
+
+
+
+);
+
+export default NotConfigured;
diff --git a/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreenPinTile.js b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreenPinTile.js
new file mode 100644
index 0000000..8ffdbfe
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreenPinTile.js
@@ -0,0 +1,42 @@
+import { useState } from 'react';
+import Tile from '@custom/shared/components/Tile';
+
+export const ScreenPinTile = ({
+ height,
+ hideName = false,
+ item,
+ maxWidth,
+ ratio: initialRatio,
+}) => {
+ const [ratio, setRatio] = useState(initialRatio);
+ const handleResize = (aspectRatio) => setRatio(aspectRatio);
+
+ if (item.isScreenshare) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ScreenPinTile;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreensAndPins.js b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreensAndPins.js
new file mode 100644
index 0000000..551ad9e
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/ScreensAndPins.js
@@ -0,0 +1,105 @@
+import { useMemo, useRef, useState } from 'react';
+
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { useResize } from '@custom/shared/hooks/useResize';
+import { useDeepCompareMemo } from 'use-deep-compare';
+import { ScreenPinTile } from './ScreenPinTile';
+
+const MAX_SCREENS_AND_PINS = 3;
+
+export const ScreensAndPins = ({ items }) => {
+ const { showNames } = useCallState();
+ const { pinnedId, sidebarView } = useUIState();
+ const viewRef = useRef(null);
+ const [dimensions, setDimensions] = useState({
+ width: 1,
+ height: 1,
+ });
+
+ useResize(() => {
+ const { width, height } = viewRef.current?.getBoundingClientRect();
+ setDimensions({
+ width,
+ height,
+ });
+ }, [viewRef, sidebarView]);
+
+ const visibleItems = useDeepCompareMemo(() => {
+ const isPinnedScreenshare = ({ id, isScreenshare }) =>
+ isScreenshare && id === pinnedId;
+ if (items.some(isPinnedScreenshare)) {
+ return items.filter(isPinnedScreenshare);
+ }
+ return items;
+ }, [items, pinnedId]);
+
+ const { height, maxWidth, aspectRatio } = useMemo(() => {
+ /**
+ * We're relying on calculating what there is room for
+ * for the total number of s+p tiles instead of using
+ * videoTrack.getSettings because (currently) getSettings
+ * is unreliable in Firefox.
+ */
+ const containerAR = dimensions.width / dimensions.height;
+ const maxItems = Math.min(visibleItems.length, MAX_SCREENS_AND_PINS);
+ const cols = Math.min(maxItems, Math.ceil(containerAR));
+ const rows = Math.ceil(visibleItems.length / cols);
+ const height = dimensions.height / rows;
+ const maxWidth = dimensions.width / cols;
+ return {
+ height,
+ maxWidth,
+ aspectRatio: maxWidth / height,
+ };
+ }, [dimensions, visibleItems?.length]);
+
+ return (
+
+ {visibleItems.map((item) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ScreensAndPins;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/SpeakerView/ScreensAndPins/index.js b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/index.js
new file mode 100644
index 0000000..228aada
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/ScreensAndPins/index.js
@@ -0,0 +1 @@
+export { ScreensAndPins } from './ScreensAndPins';
\ No newline at end of file
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js b/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js
new file mode 100644
index 0000000..c69a8e8
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerTile/SpeakerTile.js
@@ -0,0 +1,69 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import Tile from '@custom/shared/components/Tile';
+import { DEFAULT_ASPECT_RATIO } from '@custom/shared/constants';
+import { useResize } from '@custom/shared/hooks/useResize';
+import PropTypes from 'prop-types';
+
+const MAX_RATIO = DEFAULT_ASPECT_RATIO;
+const MIN_RATIO = 4 / 3;
+
+export const SpeakerTile = ({ participant, screenRef }) => {
+ const [ratio, setRatio] = useState(MAX_RATIO);
+ const [nativeAspectRatio, setNativeAspectRatio] = useState(null);
+ const [screenHeight, setScreenHeight] = useState(1);
+
+ const updateRatio = useCallback(() => {
+ const rect = screenRef.current?.getBoundingClientRect();
+ setRatio(rect.width / rect.height);
+ setScreenHeight(rect.height);
+ }, [screenRef]);
+
+ useResize(() => updateRatio(), [updateRatio]);
+ useEffect(() => updateRatio(), [updateRatio]);
+
+ /**
+ * Only use the video's native aspect ratio if it's in portrait mode
+ * (e.g. mobile) to update how we crop videos. Otherwise, use landscape
+ * defaults.
+ */
+ const handleNativeAspectRatio = (r) => {
+ const isPortrait = r < 1;
+ setNativeAspectRatio(isPortrait ? r : null);
+ };
+
+ const { height, finalRatio, videoFit } = useMemo(
+ () =>
+ // Avoid cropping mobile videos, which have the nativeAspectRatio set
+ ({
+ height: (nativeAspectRatio ?? ratio) >= MIN_RATIO ? '100%' : null,
+ finalRatio:
+ nativeAspectRatio || (ratio <= MIN_RATIO ? MIN_RATIO : MAX_RATIO),
+ videoFit: ratio >= MAX_RATIO || nativeAspectRatio ? 'contain' : 'cover',
+ }),
+ [nativeAspectRatio, ratio]
+ );
+
+ const style = {
+ height,
+ maxWidth: screenHeight * finalRatio,
+ overflow: 'hidden',
+ };
+
+ return (
+
+ );
+};
+
+SpeakerTile.propTypes = {
+ participant: PropTypes.object,
+ screenRef: PropTypes.object,
+};
+
+export default SpeakerTile;
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js b/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js
new file mode 100644
index 0000000..84639df
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerTile/index.js
@@ -0,0 +1,2 @@
+export { SpeakerTile as default } from './SpeakerTile';
+export { SpeakerTile } from './SpeakerTile';
diff --git a/custom/fitness-demo/components/SpeakerView/SpeakerView.js b/custom/fitness-demo/components/SpeakerView/SpeakerView.js
new file mode 100644
index 0000000..5a3d48a
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/SpeakerView.js
@@ -0,0 +1,123 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import ParticipantBar from '@custom/shared/components/ParticipantBar/ParticipantBar';
+import VideoContainer from '@custom/shared/components/VideoContainer';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useTracks } from '@custom/shared/contexts/TracksProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { isScreenId } from '@custom/shared/contexts/participantsState';
+import Container from '../Call/Container';
+import Header from '../Call/Header';
+import { ScreensAndPins } from './ScreensAndPins';
+import { SpeakerTile } from './SpeakerTile';
+
+const SIDEBAR_WIDTH = 186;
+
+export const SpeakerView = () => {
+ const { currentSpeaker, localParticipant, participants, screens } =
+ useParticipants();
+ const { updateCamSubscriptions } = useTracks();
+ const { showLocalVideo } = useCallState();
+ const { pinnedId } = useUIState();
+ const activeRef = useRef();
+
+ const screensAndPinned = useMemo(
+ () => [...screens, ...participants.filter(({ id }) => id === pinnedId)],
+ [participants, pinnedId, screens]
+ );
+
+ const otherParticipants = useMemo(
+ () => participants.filter(({ isLocal }) => !isLocal),
+ [participants]
+ );
+
+ const showSidebar = useMemo(() => {
+ const hasScreenshares = screens.length > 0;
+
+ if (isScreenId(pinnedId)) {
+ return false;
+ }
+
+ return participants.length > 1 || hasScreenshares;
+ }, [participants, pinnedId, screens]);
+
+ const screenShareTiles = useMemo(
+ () => ,
+ [screensAndPinned]
+ );
+
+ const hasScreenshares = useMemo(() => screens.length > 0, [screens]);
+
+ const fixedItems = useMemo(() => {
+ const items = [];
+ if (showLocalVideo) {
+ items.push(localParticipant);
+ }
+ if (hasScreenshares && otherParticipants.length > 0) {
+ items.push(otherParticipants[0]);
+ }
+ return items;
+ }, [hasScreenshares, localParticipant, otherParticipants, showLocalVideo]);
+
+ const otherItems = useMemo(() => {
+ if (otherParticipants.length > 1) {
+ return otherParticipants.slice(hasScreenshares ? 1 : 0);
+ }
+ return [];
+ }, [hasScreenshares, otherParticipants]);
+
+ /**
+ * Update cam subscriptions, in case ParticipantBar is not shown.
+ */
+ useEffect(() => {
+ // Sidebar takes care of cam subscriptions for all displayed participants.
+ if (showSidebar) return;
+ updateCamSubscriptions([
+ currentSpeaker?.id,
+ ...screensAndPinned.map((p) => p.id),
+ ]);
+ }, [currentSpeaker, screensAndPinned, showSidebar, updateCamSubscriptions]);
+
+ return (
+
+
+
+
+
+ {screensAndPinned.length > 0 ? (
+ screenShareTiles
+ ) : (
+
+ )}
+
+
+
+ {showSidebar && (
+
+ )}
+
+
+
+ );
+};
+
+export default SpeakerView;
diff --git a/custom/fitness-demo/components/SpeakerView/index.js b/custom/fitness-demo/components/SpeakerView/index.js
new file mode 100644
index 0000000..aa06afe
--- /dev/null
+++ b/custom/fitness-demo/components/SpeakerView/index.js
@@ -0,0 +1,2 @@
+export { SpeakerView as default } from './SpeakerView';
+export { SpeakerView } from './SpeakerView';
diff --git a/custom/fitness-demo/components/Tray/Chat.js b/custom/fitness-demo/components/Tray/Chat.js
new file mode 100644
index 0000000..52826e8
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/Chat.js
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import { TrayButton } from '@custom/shared/components/Tray';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconChat } from '@custom/shared/icons/chat-md.svg';
+import { useChat } from '../../contexts/ChatProvider';
+import { CHAT_ASIDE } from '../Call/ChatAside';
+
+export const ChatTray = () => {
+ const { toggleAside } = useUIState();
+ const { hasNewMessages } = useChat();
+
+ return (
+ toggleAside(CHAT_ASIDE)}
+ >
+
+
+ );
+};
+
+export default ChatTray;
diff --git a/custom/fitness-demo/components/Tray/Record.js b/custom/fitness-demo/components/Tray/Record.js
new file mode 100644
index 0000000..90d96d5
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/Record.js
@@ -0,0 +1,52 @@
+import React, { useEffect } from 'react';
+
+import { RECORDING_MODAL } from '@custom/recording/components/RecordingModal';
+import {
+ RECORDING_ERROR,
+ RECORDING_RECORDING,
+ RECORDING_SAVED,
+ RECORDING_UPLOADING,
+ useRecording,
+} from '@custom/recording/contexts/RecordingProvider';
+import { TrayButton } from '@custom/shared/components/Tray';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconRecord } from '@custom/shared/icons/record-md.svg';
+
+
+export const Tray = () => {
+ const { enableRecording } = useCallState();
+ const { openModal } = useUIState();
+ const { recordingState } = useRecording();
+ const { localParticipant } = useParticipants();
+
+ useEffect(() => {
+ console.log(`βΊοΈ Recording state: ${recordingState}`);
+
+ if (recordingState === RECORDING_ERROR) {
+ // show error modal here
+ }
+ }, [recordingState]);
+
+ const isRecording = [
+ RECORDING_RECORDING,
+ RECORDING_UPLOADING,
+ RECORDING_SAVED,
+ ].includes(recordingState);
+
+ if (!enableRecording) return null;
+ if (!localParticipant.isOwner) return null;
+
+ return (
+ openModal(RECORDING_MODAL)}
+ >
+
+
+ );
+};
+
+export default Tray;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/Tray/ScreenShare.js b/custom/fitness-demo/components/Tray/ScreenShare.js
new file mode 100644
index 0000000..6321f82
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/ScreenShare.js
@@ -0,0 +1,44 @@
+import React, { useMemo } from 'react';
+
+import { TrayButton } from '@custom/shared/components/Tray';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { ReactComponent as IconShare } from '@custom/shared/icons/share-sm.svg';
+
+const MAX_SCREEN_SHARES = 2;
+
+export const ScreenShareTray = () => {
+ const { callObject, enableScreenShare } = useCallState();
+ const { screens, participants, localParticipant } = useParticipants();
+
+ const isSharingScreen = useMemo(
+ () => screens.some((s) => s.isLocal),
+ [screens]
+ );
+
+ const screensLength = useMemo(() => screens.length, [screens]);
+
+ const toggleScreenShare = () =>
+ isSharingScreen ? callObject.stopScreenShare() : callObject.startScreenShare();
+
+ const disabled =
+ participants.length &&
+ screensLength >= MAX_SCREEN_SHARES &&
+ !isSharingScreen;
+
+ if (!enableScreenShare) return null;
+ if (!localParticipant.isOwner) return null;
+
+ return (
+
+
+
+ );
+};
+
+export default ScreenShareTray;
diff --git a/custom/fitness-demo/components/Tray/Stream.js b/custom/fitness-demo/components/Tray/Stream.js
new file mode 100644
index 0000000..a03885e
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/Stream.js
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import { LIVE_STREAMING_MODAL } from '@custom/live-streaming/components/LiveStreamingModal';
+import { useLiveStreaming } from '@custom/live-streaming/contexts/LiveStreamingProvider';
+import { TrayButton } from '@custom/shared/components/Tray';
+import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { ReactComponent as IconStream } from '@custom/shared/icons/streaming-md.svg';
+
+
+export const Stream = () => {
+ const { openModal } = useUIState();
+ const { isStreaming } = useLiveStreaming();
+ const { localParticipant } = useParticipants();
+
+ if (!localParticipant.isOwner) return null;
+
+ return (
+ openModal(LIVE_STREAMING_MODAL)}
+ >
+
+
+ );
+};
+
+export default Stream;
\ No newline at end of file
diff --git a/custom/fitness-demo/components/Tray/index.js b/custom/fitness-demo/components/Tray/index.js
new file mode 100644
index 0000000..ea94d25
--- /dev/null
+++ b/custom/fitness-demo/components/Tray/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ChatTray from './Chat';
+import RecordTray from './Record';
+import ScreenShareTray from './ScreenShare';
+import StreamTray from './Stream';
+
+export const Tray = () => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default Tray;
diff --git a/custom/fitness-demo/contexts/ChatProvider.js b/custom/fitness-demo/contexts/ChatProvider.js
new file mode 100644
index 0000000..49880f5
--- /dev/null
+++ b/custom/fitness-demo/contexts/ChatProvider.js
@@ -0,0 +1,91 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
+import { nanoid } from 'nanoid';
+import PropTypes from 'prop-types';
+
+export const ChatContext = createContext();
+
+export const ChatProvider = ({ children }) => {
+ const { callObject } = useCallState();
+ const [chatHistory, setChatHistory] = useState([]);
+ const [hasNewMessages, setHasNewMessages] = useState(false);
+
+ const handleNewMessage = useCallback(
+ (e) => {
+ if (e?.data?.message?.type) return;
+ const participants = callObject.participants();
+ const sender = participants[e.fromId].user_name
+ ? participants[e.fromId].user_name
+ : 'Guest';
+
+ setChatHistory((oldState) => [
+ ...oldState,
+ { sender, message: e.data.message, id: nanoid() },
+ ]);
+
+ setHasNewMessages(true);
+ },
+ [callObject]
+ );
+
+ const sendMessage = useCallback(
+ (message) => {
+ if (!callObject) {
+ return false;
+ }
+
+ console.log('π¬ Sending app message');
+
+ callObject.sendAppMessage({ message }, '*');
+
+ // Get the sender (local participant) name
+ const sender = callObject.participants().local.user_name
+ ? callObject.participants().local.user_name
+ : 'Guest';
+
+ // Update local chat history
+ return setChatHistory((oldState) => [
+ ...oldState,
+ { sender, message, id: nanoid(), isLocal: true },
+ ]);
+ },
+ [callObject]
+ );
+
+ useEffect(() => {
+ if (!callObject) {
+ return false;
+ }
+
+ console.log(`π¬ Chat provider listening for app messages`);
+
+ callObject.on('app-message', handleNewMessage);
+
+ return () => callObject.off('app-message', handleNewMessage);
+ }, [callObject, handleNewMessage]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+ChatProvider.propTypes = {
+ children: PropTypes.node,
+};
+
+export const useChat = () => useContext(ChatContext);
\ No newline at end of file
diff --git a/custom/fitness-demo/contexts/ClassStateProvider.js b/custom/fitness-demo/contexts/ClassStateProvider.js
new file mode 100644
index 0000000..ee7b07e
--- /dev/null
+++ b/custom/fitness-demo/contexts/ClassStateProvider.js
@@ -0,0 +1,48 @@
+import React, {
+ createContext,
+ useContext,
+ useCallback,
+ useMemo,
+ useEffect,
+} from 'react';
+import { useUIState, VIEW_MODE_SPEAKER, VIEW_MODE_GRID } from '@custom/shared/contexts/UIStateProvider';
+import { useSharedState } from '@custom/shared/hooks/useSharedState';
+import PropTypes from 'prop-types';
+
+export const PRE_CLASS_LOBBY = 'Pre-class lobby';
+export const CLASS_IN_SESSION = 'Class-in session';
+export const POST_CLASS_LOBBY = 'Post-class lobby';
+
+export const ClassStateContext = createContext();
+
+export const ClassStateProvider = ({ children }) => {
+ const { setPreferredViewMode } = useUIState();
+
+ const { sharedState, setSharedState } = useSharedState({
+ initialValues: { type: PRE_CLASS_LOBBY },
+ });
+
+ const classType = useMemo(() => sharedState.type, [sharedState.type]);
+
+ const setClassType = useCallback(() => {
+ if (sharedState.type === PRE_CLASS_LOBBY) setSharedState({ type: CLASS_IN_SESSION });
+ if (sharedState.type === CLASS_IN_SESSION) setSharedState({ type: POST_CLASS_LOBBY });
+ }, [sharedState.type, setSharedState]);
+
+ useEffect(() => {
+ if (sharedState.type === CLASS_IN_SESSION) setPreferredViewMode(VIEW_MODE_SPEAKER);
+ else setPreferredViewMode(VIEW_MODE_GRID);
+ }, [setPreferredViewMode, sharedState.type]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+ClassStateProvider.propTypes = {
+ children: PropTypes.node,
+};
+
+export const useClassState = () => useContext(ClassStateContext);
\ No newline at end of file
diff --git a/custom/fitness-demo/env.example b/custom/fitness-demo/env.example
new file mode 100644
index 0000000..697604a
--- /dev/null
+++ b/custom/fitness-demo/env.example
@@ -0,0 +1,11 @@
+# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
+DAILY_DOMAIN=
+
+# Obtained from https://dashboard.daily.co/developers
+DAILY_API_KEY=
+
+# Daily REST API endpoint
+DAILY_REST_DOMAIN=https://api.daily.co/v1
+
+# Enable manual track subscriptions
+MANUAL_TRACK_SUBS=1
\ No newline at end of file
diff --git a/custom/fitness-demo/image.png b/custom/fitness-demo/image.png
new file mode 100644
index 0000000..9dc90de
Binary files /dev/null and b/custom/fitness-demo/image.png differ
diff --git a/custom/fitness-demo/index.js b/custom/fitness-demo/index.js
new file mode 100644
index 0000000..9044efc
--- /dev/null
+++ b/custom/fitness-demo/index.js
@@ -0,0 +1 @@
+// Note: I am here because next-transpile-modules requires a mainfile
diff --git a/custom/fitness-demo/next.config.js b/custom/fitness-demo/next.config.js
new file mode 100644
index 0000000..ec50e25
--- /dev/null
+++ b/custom/fitness-demo/next.config.js
@@ -0,0 +1,16 @@
+const withPlugins = require('next-compose-plugins');
+const withTM = require('next-transpile-modules')([
+ '@custom/shared',
+ '@custom/basic-call',
+ '@custom/text-chat',
+ '@custom/live-streaming',
+ '@custom/recording',
+]);
+
+const packageJson = require('./package.json');
+
+module.exports = withPlugins([withTM], {
+ env: {
+ PROJECT_TITLE: packageJson.description,
+ },
+});
diff --git a/custom/fitness-demo/package.json b/custom/fitness-demo/package.json
new file mode 100644
index 0000000..fd34066
--- /dev/null
+++ b/custom/fitness-demo/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@custom/fitness-demo",
+ "description": "Basic Call + Fitness Demo",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@custom/shared": "*",
+ "@custom/basic-call": "*",
+ "@custom/text-chat": "*",
+ "@custom/live-streaming": "*",
+ "@custom/recording": "*",
+ "next": "^11.1.2",
+ "pluralize": "^8.0.0"
+ },
+ "devDependencies": {
+ "babel-plugin-module-resolver": "^4.1.0",
+ "next-compose-plugins": "^2.2.1",
+ "next-transpile-modules": "^8.0.0"
+ }
+}
diff --git a/custom/fitness-demo/pages/[room].js b/custom/fitness-demo/pages/[room].js
new file mode 100644
index 0000000..cc035bb
--- /dev/null
+++ b/custom/fitness-demo/pages/[room].js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { CallProvider } from '@custom/shared/contexts/CallProvider';
+import { MediaDeviceProvider } from '@custom/shared/contexts/MediaDeviceProvider';
+import { ParticipantsProvider } from '@custom/shared/contexts/ParticipantsProvider';
+import { TracksProvider } from '@custom/shared/contexts/TracksProvider';
+import { UIStateProvider } from '@custom/shared/contexts/UIStateProvider';
+import { WaitingRoomProvider } from '@custom/shared/contexts/WaitingRoomProvider';
+import getDemoProps from '@custom/shared/lib/demoProps';
+import { useRouter } from 'next/router';
+import App from '../components/App';
+import NotConfigured from '../components/Prejoin/NotConfigured';
+
+const Room = ({
+ domain,
+ isConfigured = false,
+ subscribeToTracksAutomatically = true,
+ asides,
+ modals,
+ customTrayComponent,
+ customAppComponent,
+}) => {
+ const router = useRouter();
+ const { room, t } = router.query;
+
+ if (!isConfigured) return ;
+ return (
+
+
+
+
+
+
+ {customAppComponent || }
+
+
+
+
+
+
+ )
+};
+
+export default Room;
+
+export async function getStaticProps() {
+ const defaultProps = getDemoProps();
+ return {
+ props: defaultProps,
+ };
+}
+
+export async function getStaticPaths() {
+ return {
+ paths: [],
+ fallback: 'blocking',
+ }
+}
\ No newline at end of file
diff --git a/custom/fitness-demo/pages/_app.js b/custom/fitness-demo/pages/_app.js
new file mode 100644
index 0000000..19357e0
--- /dev/null
+++ b/custom/fitness-demo/pages/_app.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import LiveStreamingModal from '@custom/live-streaming/components/LiveStreamingModal';
+import RecordingModal from '@custom/recording/components/RecordingModal';
+import GlobalStyle from '@custom/shared/components/GlobalStyle';
+import Head from 'next/head';
+import PropTypes from 'prop-types';
+import { App as CustomApp } from '../components/App/App';
+import ChatAside from '../components/Call/ChatAside';
+import Tray from '../components/Tray';
+
+function App({ Component, pageProps }) {
+ return (
+ <>
+
+ Daily - {process.env.PROJECT_TITLE}
+
+
+
+ >
+ );
+}
+
+App.defaultProps = {
+ Component: null,
+ pageProps: {},
+};
+
+App.propTypes = {
+ Component: PropTypes.elementType,
+ pageProps: PropTypes.object,
+};
+
+App.asides = [ChatAside];
+App.modals = [RecordingModal, LiveStreamingModal];
+App.customTrayComponent = ;
+App.customAppComponent = ;
+
+export default App;
diff --git a/custom/fitness-demo/pages/_document.js b/custom/fitness-demo/pages/_document.js
new file mode 100644
index 0000000..d57b011
--- /dev/null
+++ b/custom/fitness-demo/pages/_document.js
@@ -0,0 +1,23 @@
+import Document, { Html, Head, Main, NextScript } from 'next/document';
+
+class MyDocument extends Document {
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default MyDocument;
diff --git a/custom/fitness-demo/pages/api/createRoom.js b/custom/fitness-demo/pages/api/createRoom.js
new file mode 100644
index 0000000..beff1aa
--- /dev/null
+++ b/custom/fitness-demo/pages/api/createRoom.js
@@ -0,0 +1,44 @@
+export default async function handler(req, res) {
+ const { roomName, privacy, expiryMinutes, ...rest } = req.body;
+
+ if (req.method === 'POST') {
+ console.log(`Creating room on domain ${process.env.DAILY_DOMAIN}`);
+
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
+ },
+ body: JSON.stringify({
+ name: roomName,
+ privacy: privacy || 'public',
+ properties: {
+ exp: Math.round(Date.now() / 1000) + (expiryMinutes || 5) * 60, // expire in x minutes
+ eject_at_room_exp: true,
+ enable_knocking: privacy !== 'public',
+ enable_screenshare: true,
+ enable_recording: 'local',
+ ...rest,
+ },
+ }),
+ };
+
+ const dailyRes = await fetch(
+ `${process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'}/rooms`,
+ options
+ );
+
+ const { name, url, error } = await dailyRes.json();
+
+ if (error) {
+ return res.status(500).json({ error });
+ }
+
+ return res
+ .status(200)
+ .json({ name, url, domain: process.env.DAILY_DOMAIN });
+ }
+
+ return res.status(500);
+}
diff --git a/custom/fitness-demo/pages/api/editRoom.js b/custom/fitness-demo/pages/api/editRoom.js
new file mode 100644
index 0000000..9d27dae
--- /dev/null
+++ b/custom/fitness-demo/pages/api/editRoom.js
@@ -0,0 +1,42 @@
+export default async function handler(req, res) {
+ const { roomName } = req.query;
+ const { privacy, expiryMinutes, ...rest } = req.body;
+
+ if (req.method === 'POST') {
+ console.log(`Modifying room on domain ${process.env.DAILY_DOMAIN}`);
+
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
+ },
+ body: JSON.stringify({
+ privacy: privacy || 'public',
+ properties: {
+ exp: Math.round(Date.now() / 1000) + (expiryMinutes || 5) * 60, // expire in x minutes
+ eject_at_room_exp: true,
+ enable_knocking: privacy !== 'public',
+ ...rest,
+ },
+ }),
+ };
+
+ const dailyRes = await fetch(
+ `${process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'}/rooms/${roomName}`,
+ options
+ );
+
+ const { name, url, error } = await dailyRes.json();
+
+ if (error) {
+ return res.status(500).json({ error });
+ }
+
+ return res
+ .status(200)
+ .json({ name, url, domain: process.env.DAILY_DOMAIN });
+ }
+
+ return res.status(500);
+}
diff --git a/custom/fitness-demo/pages/api/presence.js b/custom/fitness-demo/pages/api/presence.js
new file mode 100644
index 0000000..03ca00b
--- /dev/null
+++ b/custom/fitness-demo/pages/api/presence.js
@@ -0,0 +1,27 @@
+/*
+ * This is an example server-side function that provides the real-time presence
+ * data of all the active rooms in the given domain.
+ */
+export default async function handler(req, res) {
+ if (req.method === 'GET') {
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
+ },
+ };
+
+ const dailyRes = await fetch(
+ `${
+ process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'
+ }/presence`,
+ options
+ );
+
+ const response = await dailyRes.json();
+ return res.status(200).json(response);
+ }
+
+ return res.status(500);
+}
diff --git a/custom/fitness-demo/pages/api/room.js b/custom/fitness-demo/pages/api/room.js
new file mode 100644
index 0000000..75d03ab
--- /dev/null
+++ b/custom/fitness-demo/pages/api/room.js
@@ -0,0 +1,28 @@
+/*
+ * This is an example server-side function that retrieves the room object.
+ */
+export default async function handler(req, res) {
+ const { name } = req.query;
+
+ if (req.method === 'GET') {
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
+ },
+ };
+
+ const dailyRes = await fetch(
+ `${
+ process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'
+ }/rooms/${name}`,
+ options
+ );
+
+ const response = await dailyRes.json();
+ return res.status(200).json(response);
+ }
+
+ return res.status(500);
+}
diff --git a/custom/fitness-demo/pages/api/token.js b/custom/fitness-demo/pages/api/token.js
new file mode 100644
index 0000000..37137da
--- /dev/null
+++ b/custom/fitness-demo/pages/api/token.js
@@ -0,0 +1,40 @@
+/*
+ * This is an example server-side function that generates a meeting token
+ * server-side. You could replace this on your own back-end to include
+ * custom user authentication, etc.
+ */
+export default async function handler(req, res) {
+ const { roomName, isOwner } = req.body;
+
+ if (req.method === 'POST' && roomName) {
+ console.log(`Getting token for room '${roomName}' as owner: ${isOwner}`);
+
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
+ },
+ body: JSON.stringify({
+ properties: { room_name: roomName, is_owner: isOwner },
+ }),
+ };
+
+ const dailyRes = await fetch(
+ `${
+ process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'
+ }/meeting-tokens`,
+ options
+ );
+
+ const { token, error } = await dailyRes.json();
+
+ if (error) {
+ return res.status(500).json({ error });
+ }
+
+ return res.status(200).json({ token, domain: process.env.DAILY_DOMAIN });
+ }
+
+ return res.status(500);
+}
diff --git a/custom/fitness-demo/pages/index.js b/custom/fitness-demo/pages/index.js
new file mode 100644
index 0000000..7591688
--- /dev/null
+++ b/custom/fitness-demo/pages/index.js
@@ -0,0 +1,165 @@
+import React, { useState, useCallback } from 'react';
+import getDemoProps from '@custom/shared/lib/demoProps';
+import { useRouter } from 'next/router';
+import PropTypes from 'prop-types';
+import Intro from '../components/Prejoin/Intro';
+import NotConfigured from '../components/Prejoin/NotConfigured';
+
+/**
+ * Index page
+ * ---
+ * - Checks configuration variables are set in local env
+ * - Optionally obtain a meeting token from Daily REST API (./pages/api/token)
+ * - Set call owner status
+ * - Finally, renders the main application loop
+ */
+export default function Index({
+ domain,
+ isConfigured = false,
+}) {
+ const router = useRouter();
+ const [fetching, setFetching] = useState(false);
+ const [error, setError] = useState();
+
+ const [fetchingToken, setFetchingToken] = useState(false);
+ const [tokenError, setTokenError] = useState();
+
+ const getMeetingToken = useCallback(async (room, isOwner = false) => {
+ if (!room) return false;
+
+ setFetchingToken(true);
+
+ // Fetch token from serverside method (provided by Next)
+ const res = await fetch('/api/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ roomName: room, isOwner }),
+ });
+ const resJson = await res.json();
+
+ if (!resJson?.token) {
+ setTokenError(resJson?.error || true);
+ setFetchingToken(false);
+ return false;
+ }
+
+ console.log(`πͺ Token received`);
+
+ setFetchingToken(false);
+ await router.push(`/${room}?t=${resJson.token}`);
+
+ return true;
+ }, [router]);
+
+ const createRoom = async (room, duration, privacy) => {
+ setError(false);
+ setFetching(true);
+
+ console.log(`πͺ Verifying if there's a class with same name`);
+
+ const verifyingRes = await fetch(`/api/room?name=${room}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const verifyingResJson = await verifyingRes.json();
+
+ // it throws an error saying not-found if the room doesn't exist.
+ // so we create a new room here.
+ if (verifyingResJson.error === 'not-found') {
+ console.log(`πͺ Creating a new class...`);
+
+ // Create a room server side (using Next JS serverless)
+ const res = await fetch('/api/createRoom', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ roomName: room,
+ expiryMinutes: Number(duration),
+ privacy: !privacy ? 'private': 'public'
+ }),
+ });
+
+ const resJson = await res.json();
+
+ if (resJson.name) {
+ await getMeetingToken(resJson.name, true);
+ return;
+ }
+
+ setError(resJson?.info || resJson?.error || 'An unknown error occured');
+ } else {
+ if (verifyingResJson.name) {
+ const editRes = await fetch(`/api/editRoom?roomName=${room}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ expiryMinutes: Number(duration),
+ privacy: !privacy ? 'private': 'public'
+ }),
+ });
+
+ const editResJson = await editRes.json();
+ await getMeetingToken(editResJson.name, true);
+ return;
+ }
+ }
+
+ setFetching(false);
+ }
+
+ /**
+ * Main call UI
+ */
+ return (
+
+ {(() => {
+ if (!isConfigured) return ;
+ return (
+
+ type === 'join' ? router.push(`/${room}`): createRoom(room, duration, privacy)
+ }
+ />
+ );
+ })()}
+
+
+
+ );
+}
+
+Index.propTypes = {
+ isConfigured: PropTypes.bool.isRequired,
+ domain: PropTypes.string,
+ asides: PropTypes.arrayOf(PropTypes.func),
+ modals: PropTypes.arrayOf(PropTypes.func),
+ customTrayComponent: PropTypes.node,
+ customAppComponent: PropTypes.node,
+ subscribeToTracksAutomatically: PropTypes.bool,
+};
+
+export async function getStaticProps() {
+ const defaultProps = getDemoProps();
+ return {
+ props: defaultProps,
+ };
+}
diff --git a/custom/fitness-demo/pages/not-found.js b/custom/fitness-demo/pages/not-found.js
new file mode 100644
index 0000000..1d25fb7
--- /dev/null
+++ b/custom/fitness-demo/pages/not-found.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import MessageCard from '@custom/shared/components/MessageCard';
+
+export default function RoomNotFound() {
+ return (
+
+
+ The room you are trying to join does not exist. Have you created the
+ room using the Daily REST API or the dashboard?
+
+
+
+ );
+}
diff --git a/custom/fitness-demo/public/assets/daily-logo-dark.svg b/custom/fitness-demo/public/assets/daily-logo-dark.svg
new file mode 100644
index 0000000..ef3a565
--- /dev/null
+++ b/custom/fitness-demo/public/assets/daily-logo-dark.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/custom/fitness-demo/public/assets/daily-logo.svg b/custom/fitness-demo/public/assets/daily-logo.svg
new file mode 100644
index 0000000..534a18a
--- /dev/null
+++ b/custom/fitness-demo/public/assets/daily-logo.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/custom/fitness-demo/public/assets/join.mp3 b/custom/fitness-demo/public/assets/join.mp3
new file mode 100644
index 0000000..7657915
Binary files /dev/null and b/custom/fitness-demo/public/assets/join.mp3 differ
diff --git a/custom/fitness-demo/public/assets/message.mp3 b/custom/fitness-demo/public/assets/message.mp3
new file mode 100644
index 0000000..a067315
Binary files /dev/null and b/custom/fitness-demo/public/assets/message.mp3 differ
diff --git a/custom/fitness-demo/public/assets/pattern-bg.png b/custom/fitness-demo/public/assets/pattern-bg.png
new file mode 100644
index 0000000..01e0d0d
Binary files /dev/null and b/custom/fitness-demo/public/assets/pattern-bg.png differ
diff --git a/custom/fitness-demo/public/assets/pattern-ls.svg b/custom/fitness-demo/public/assets/pattern-ls.svg
new file mode 100644
index 0000000..8b07388
--- /dev/null
+++ b/custom/fitness-demo/public/assets/pattern-ls.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/custom/shared/components/Aside/NetworkAside.js b/custom/shared/components/Aside/NetworkAside.js
index 0f4d61c..0469039 100644
--- a/custom/shared/components/Aside/NetworkAside.js
+++ b/custom/shared/components/Aside/NetworkAside.js
@@ -22,9 +22,7 @@ export const NetworkAside = () => {
}, [callObject]);
useEffect(() => {
- if (!callObject) {
- return;
- }
+ if (!callObject) return;
updateStats();
@@ -38,7 +36,7 @@ export const NetworkAside = () => {
Math.round(
(networkStats?.stats?.latest?.videoRecvBitsPerSecond ?? 0) / 1000
),
- [networkStats]
+ [networkStats?.stats?.latest?.videoRecvBitsPerSecond]
);
const uploadKbs = useMemo(
@@ -46,7 +44,7 @@ export const NetworkAside = () => {
Math.round(
(networkStats?.stats?.latest?.videoSendBitsPerSecond ?? 0) / 1000
),
- [networkStats]
+ [networkStats?.stats?.latest?.videoSendBitsPerSecond]
);
if (!showAside || showAside !== NETWORK_ASIDE) {
diff --git a/custom/shared/components/Aside/PeopleAside.js b/custom/shared/components/Aside/PeopleAside.js
index d5dc374..feb7f12 100644
--- a/custom/shared/components/Aside/PeopleAside.js
+++ b/custom/shared/components/Aside/PeopleAside.js
@@ -97,7 +97,7 @@ PersonRow.propTypes = {
export const PeopleAside = () => {
const { callObject } = useCallState();
const { showAside, setShowAside } = useUIState();
- const { allParticipants, isOwner } = useParticipants();
+ const { participants, isOwner } = useParticipants();
if (!showAside || showAside !== PEOPLE_ASIDE) {
return null;
@@ -131,7 +131,7 @@ export const PeopleAside = () => {
)}
- {allParticipants.map((p) => (
+ {participants.map((p) => (
))}
diff --git a/custom/shared/components/Audio/Audio.js b/custom/shared/components/Audio/Audio.js
index a806340..a4d74dc 100644
--- a/custom/shared/components/Audio/Audio.js
+++ b/custom/shared/components/Audio/Audio.js
@@ -8,22 +8,30 @@
* into into a single audio node using the CombinedAudioTrack component
*/
import React, { useEffect, useMemo } from 'react';
+import { useCallState } from '@custom/shared/contexts/CallProvider';
import { useTracks } from '@custom/shared/contexts/TracksProvider';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { isScreenId } from '@custom/shared/contexts/participantsState';
import Bowser from 'bowser';
import { Portal } from 'react-portal';
import AudioTrack from './AudioTrack';
import CombinedAudioTrack from './CombinedAudioTrack';
+
export const Audio = () => {
+ const { disableAudio } = useCallState();
const { audioTracks } = useTracks();
+ const { setShowAutoplayFailedModal } = useUIState();
const renderedTracks = useMemo(
() =>
- Object.entries(audioTracks).reduce(
- (tracks, [id, track]) => ({ ...tracks, [id]: track }),
- {}
- ),
- [audioTracks]
+ Object.entries(audioTracks).reduce((tracks, [id, track]) => {
+ if (!disableAudio || isScreenId(id)) {
+ tracks[id] = track;
+ }
+ return tracks;
+ }, {}),
+ [audioTracks, disableAudio]
);
// On iOS safari, when headphones are disconnected, all audio elements are paused.
@@ -32,25 +40,31 @@ export const Audio = () => {
// To fix that, we call `play` on each audio track on all devicechange events.
useEffect(() => {
const playTracks = () => {
- document.querySelectorAll('.audioTracks audio').forEach(async (audio) => {
- try {
- if (audio.paused && audio.readyState === audio.HAVE_ENOUGH_DATA) {
- await audio?.play();
+ document
+ .querySelectorAll('.audioTracks audio')
+ .forEach(async (audio) => {
+ try {
+ if (audio.paused && audio.readyState === audio.HAVE_ENOUGH_DATA) {
+ await audio?.play();
+ }
+ } catch (e) {
+ setShowAutoplayFailedModal(true);
}
- } catch (e) {
- // Auto play failed
- }
- });
+ });
};
navigator.mediaDevices.addEventListener('devicechange', playTracks);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', playTracks);
};
- }, []);
+ }, [setShowAutoplayFailedModal]);
const tracksComponent = useMemo(() => {
- const { browser } = Bowser.parse(navigator.userAgent);
- if (browser.name === 'Chrome' && parseInt(browser.version, 10) >= 92) {
+ const { browser, platform, os } = Bowser.parse(navigator.userAgent);
+ if (
+ browser.name === 'Chrome' &&
+ parseInt(browser.version, 10) >= 92 &&
+ (platform.type === 'desktop' || os.name === 'Android')
+ ) {
return ;
}
return Object.entries(renderedTracks).map(([id, track]) => (
diff --git a/custom/shared/components/Audio/AudioTrack.js b/custom/shared/components/Audio/AudioTrack.js
index 82b22db..5da059d 100644
--- a/custom/shared/components/Audio/AudioTrack.js
+++ b/custom/shared/components/Audio/AudioTrack.js
@@ -1,38 +1,35 @@
import React, { useRef, useEffect } from 'react';
+import { useUIState } from '@custom/shared/contexts/UIStateProvider';
import PropTypes from 'prop-types';
-const AudioTrack = ({ track }) => {
+export const AudioTrack = ({ track }) => {
const audioRef = useRef(null);
+ const { setShowAutoplayFailedModal } = useUIState();
useEffect(() => {
- if (!audioRef.current) return false;
+ const audioTag = audioRef.current;
+ if (!audioTag) return false;
let playTimeout;
const handleCanPlay = () => {
playTimeout = setTimeout(() => {
- console.log('Unable to autoplay audio element');
+ setShowAutoplayFailedModal(true);
}, 1500);
};
const handlePlay = () => {
clearTimeout(playTimeout);
};
- audioRef.current.addEventListener('canplay', handleCanPlay);
- audioRef.current.addEventListener('play', handlePlay);
- audioRef.current.srcObject = new MediaStream([track]);
-
- const audioEl = audioRef.current;
+ audioTag.addEventListener('canplay', handleCanPlay);
+ audioTag.addEventListener('play', handlePlay);
+ audioTag.srcObject = new MediaStream([track]);
return () => {
- audioEl?.removeEventListener('canplay', handleCanPlay);
- audioEl?.removeEventListener('play', handlePlay);
+ audioTag?.removeEventListener('canplay', handleCanPlay);
+ audioTag?.removeEventListener('play', handlePlay);
};
- }, [track]);
+ }, [setShowAutoplayFailedModal, track]);
- return track ? (
-
-
-
- ) : null;
+ return track ? : null;
};
AudioTrack.propTypes = {
diff --git a/custom/shared/components/Audio/CombinedAudioTrack.js b/custom/shared/components/Audio/CombinedAudioTrack.js
index 0e81f23..0082860 100644
--- a/custom/shared/components/Audio/CombinedAudioTrack.js
+++ b/custom/shared/components/Audio/CombinedAudioTrack.js
@@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDeepCompareEffect, useDeepCompareMemo } from 'use-deep-compare';
-const CombinedAudioTrack = ({ tracks }) => {
+export const CombinedAudioTrack = ({ tracks }) => {
const audioEl = useRef(null);
useEffect(() => {
@@ -25,12 +25,21 @@ const CombinedAudioTrack = ({ tracks }) => {
allTracks.forEach((track) => {
const persistentTrack = track?.persistentTrack;
if (persistentTrack) {
- persistentTrack.addEventListener(
- 'ended',
- (ev) => stream.removeTrack(ev.target),
- { once: true }
- );
- stream.addTrack(persistentTrack);
+ switch (persistentTrack.readyState) {
+ case 'ended':
+ stream.removeTrack(persistentTrack);
+ break;
+ case 'live':
+ persistentTrack.addEventListener(
+ 'ended',
+ (ev) => {
+ stream.removeTrack(ev.target);
+ },
+ { once: true }
+ );
+ stream.addTrack(persistentTrack);
+ break;
+ }
}
});
@@ -53,11 +62,7 @@ const CombinedAudioTrack = ({ tracks }) => {
playAudio();
}, [tracks, trackIds]);
- return (
-
-
-
- );
+ return ;
};
CombinedAudioTrack.propTypes = {
diff --git a/custom/shared/components/Button/Button.js b/custom/shared/components/Button/Button.js
index bb3d466..ba87bac 100644
--- a/custom/shared/components/Button/Button.js
+++ b/custom/shared/components/Button/Button.js
@@ -228,6 +228,13 @@ export const Button = forwardRef(
width: 64px;
border-radius: 32px;
}
+
+ .button.small-circle {
+ padding: 0px;
+ height: 42px;
+ width: 42px;
+ border-radius: 21px;
+ }
.button.translucent {
background: ${hexa(theme.blue.light, 0.35)};
@@ -289,6 +296,24 @@ export const Button = forwardRef(
.button.dark:disabled {
opacity: 0.35;
}
+
+ .button.gray {
+ background: ${theme.gray.light};
+ color: var(--text-default);
+ border: 0;
+ }
+ .button.gray:hover,
+ .button.gray:focus,
+ .button.gray:active {
+ background: ${theme.gray.default};
+ border: 0;
+ }
+ .button.gray:focus {
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
+ }
+ .button.gray:disabled {
+ opacity: 0.35;
+ }
.button.white {
background: white;
diff --git a/custom/shared/components/Capsule/Capsule.js b/custom/shared/components/Capsule/Capsule.js
index 09362b0..1849715 100644
--- a/custom/shared/components/Capsule/Capsule.js
+++ b/custom/shared/components/Capsule/Capsule.js
@@ -6,18 +6,19 @@ export const Capsule = ({ children, variant }) => (
{children}
);
@@ -26,7 +34,6 @@ export const CardHeader = ({ children }) => (
h2 {
font-size: 1.375rem;
margin: 0px;
- color: var(--text-default);
}
& + :global(.card-body) {
@@ -43,7 +50,9 @@ export const CardBody = ({ children }) => (
{children}
);
diff --git a/custom/shared/components/ParticipantBar/ParticipantBar.js b/custom/shared/components/ParticipantBar/ParticipantBar.js
index 7552fdb..bfe4c05 100644
--- a/custom/shared/components/ParticipantBar/ParticipantBar.js
+++ b/custom/shared/components/ParticipantBar/ParticipantBar.js
@@ -240,7 +240,7 @@ export const ParticipantBar = ({
const maybePromoteActiveSpeaker = () => {
const fixedOther = fixed.find((f) => !f.isLocal);
// Ignore when speaker is already at first position or component unmounted
- if (!fixedOther || fixedOther?.id === activeSpeakerId || !scrollEl) {
+ if (!fixedOther || fixedOther?.id === currentSpeakerId || !scrollEl) {
return false;
}
diff --git a/custom/shared/components/Tile/Tile.js b/custom/shared/components/Tile/Tile.js
index a65b0f4..621eb39 100644
--- a/custom/shared/components/Tile/Tile.js
+++ b/custom/shared/components/Tile/Tile.js
@@ -1,10 +1,10 @@
import React, { memo, useEffect, useState, useRef } from 'react';
-import useVideoTrack from '@custom/shared/hooks/useVideoTrack';
+import { useVideoTrack } from '@custom/shared/hooks/useVideoTrack';
import { ReactComponent as IconMicMute } from '@custom/shared/icons/mic-off-sm.svg';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { DEFAULT_ASPECT_RATIO } from '../../constants';
-import Video from './Video';
+import { Video } from './Video';
import { ReactComponent as Avatar } from './avatar.svg';
const SM_TILE_MAX_WIDTH = 300;
@@ -21,7 +21,7 @@ export const Tile = memo(
onVideoResize,
...props
}) => {
- const videoTrack = useVideoTrack(participant);
+ const videoTrack = useVideoTrack(participant.id);
const videoRef = useRef(null);
const tileRef = useRef(null);
const [tileWidth, setTileWidth] = useState(0);
@@ -99,8 +99,9 @@ export const Tile = memo(
{videoTrack ? (
) : (
showAvatar && (
diff --git a/custom/shared/components/Tile/Video.js b/custom/shared/components/Tile/Video.js
index 660c8cc..267057e 100644
--- a/custom/shared/components/Tile/Video.js
+++ b/custom/shared/components/Tile/Video.js
@@ -1,14 +1,30 @@
-import React, { useMemo, forwardRef, memo, useEffect } from 'react';
+import {
+ forwardRef,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
import Bowser from 'bowser';
-import PropTypes from 'prop-types';
-import { shallowEqualObjects } from 'shallow-equal';
+import classNames from 'classnames';
+
+import { useCallState } from '../../contexts/CallProvider';
+import { useUIState } from '../../contexts/UIStateProvider';
+import { useVideoTrack } from '../../hooks/useVideoTrack';
+
+export const Video = forwardRef(
+ (
+ { fit = 'contain', isScreen = false, participantId, ...props },
+ videoEl
+ ) => {
+ const { callObject: daily } = useCallState();
+ const { isMobile } = useUIState();
+ const isLocalCam = useMemo(() => {
+ const localParticipant = daily.participants()?.local;
+ return participantId === localParticipant.session_id && !isScreen;
+ }, [daily, isScreen, participantId]);
+ const [isMirrored, setIsMirrored] = useState(isLocalCam);
+ const videoTrack = useVideoTrack(participantId);
-export const Video = memo(
- forwardRef(({ participantId, videoTrack, ...rest }, videoEl) => {
- /**
- * Memo: Chrome >= 92?
- * See: https://bugs.chromium.org/p/chromium/issues/detail?id=1232649
- */
const isChrome92 = useMemo(() => {
const { browser, platform, os } = Bowser.parse(navigator.userAgent);
return (
@@ -19,43 +35,114 @@ export const Video = memo(
}, []);
/**
- * Effect: Umount
- * Note: nullify src to ensure media object is not counted
+ * Determine if video needs to be mirrored.
+ */
+ useEffect(() => {
+ if (!videoTrack) return;
+
+ const videoTrackSettings = videoTrack.getSettings();
+ const isUsersFrontCamera =
+ 'facingMode' in videoTrackSettings
+ ? isLocalCam && videoTrackSettings.facingMode === 'user'
+ : isLocalCam;
+ // only apply mirror effect to user facing camera
+ if (isMirrored !== isUsersFrontCamera) {
+ setIsMirrored(isUsersFrontCamera);
+ }
+ }, [isMirrored, isLocalCam, videoTrack]);
+
+ /**
+ * Handle canplay & picture-in-picture events.
*/
useEffect(() => {
const video = videoEl.current;
- if (!video) return false;
- // clean up when video renders for different participant
- video.srcObject = null;
- if (isChrome92) video.load();
- return () => {
- // clean up when unmounted
- video.srcObject = null;
- if (isChrome92) video.load();
+ if (!video) return;
+ const handleCanPlay = () => {
+ if (!video.paused) return;
+ video.play();
};
- }, [videoEl, isChrome92, participantId]);
+ const handleEnterPIP = () => {
+ video.style.transform = 'scale(1)';
+ };
+ const handleLeavePIP = () => {
+ video.style.transform = '';
+ setTimeout(() => {
+ if (video.paused) video.play();
+ }, 100);
+ };
+ video.addEventListener('canplay', handleCanPlay);
+ video.addEventListener('enterpictureinpicture', handleEnterPIP);
+ video.addEventListener('leavepictureinpicture', handleLeavePIP);
+ return () => {
+ video.removeEventListener('canplay', handleCanPlay);
+ video.removeEventListener('enterpictureinpicture', handleEnterPIP);
+ video.removeEventListener('leavepictureinpicture', handleLeavePIP);
+ };
+ }, [isChrome92, videoEl]);
/**
- * Effect: mount source (and force load on Chrome)
+ * Update srcObject.
*/
useEffect(() => {
const video = videoEl.current;
if (!video || !videoTrack) return;
video.srcObject = new MediaStream([videoTrack]);
if (isChrome92) video.load();
- }, [videoEl, isChrome92, videoTrack]);
+ return () => {
+ // clean up when unmounted
+ video.srcObject = null;
+ if (isChrome92) video.load();
+ };
+ }, [isChrome92, participantId, videoEl, videoTrack, videoTrack?.id]);
- return ;
- }),
- (p, n) => shallowEqualObjects(p, n)
+ return (
+ <>
+
+
+ >
+ );
+ }
);
-
-Video.displayName = 'Video';
-
-Video.propTypes = {
- videoTrack: PropTypes.any,
- mirrored: PropTypes.bool,
- participantId: PropTypes.string,
-};
-
-export default Video;
+Video.displayName = 'Video';
\ No newline at end of file
diff --git a/custom/shared/components/Tray/BasicTray.js b/custom/shared/components/Tray/BasicTray.js
index 94bbbcc..4ad4b41 100644
--- a/custom/shared/components/Tray/BasicTray.js
+++ b/custom/shared/components/Tray/BasicTray.js
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useRef, useState, useEffect } from 'react';
import { NETWORK_ASIDE } from '@custom/shared/components/Aside/NetworkAside';
import { PEOPLE_ASIDE } from '@custom/shared/components/Aside/PeopleAside';
import Button from '@custom/shared/components/Button';
@@ -19,6 +19,7 @@ import { ReactComponent as IconSettings } from '@custom/shared/icons/settings-md
import { Tray, TrayButton } from './Tray';
export const BasicTray = () => {
+ const ref = useRef(null);
const responsive = useResponsive();
const [showMore, setShowMore] = useState(false);
const { callObject, leave } = useCallState();
@@ -35,6 +36,18 @@ export const BasicTray = () => {
return callObject.setLocalAudio(newState);
};
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (ref.current && !ref.current.contains(event.target))
+ setShowMore(false);
+ };
+
+ document.addEventListener('click', handleClickOutside, true);
+ return () => {
+ document.removeEventListener('click', handleClickOutside, true);
+ };
+ }, []);
+
return (
{
{isMicMuted ? : }
{responsive.isMobile() && showMore && (
-
+
openModal(DEVICE_MODAL)}
diff --git a/custom/shared/components/Tray/TrayMicButton.js b/custom/shared/components/Tray/TrayMicButton.js
index f88a476..878c52a 100644
--- a/custom/shared/components/Tray/TrayMicButton.js
+++ b/custom/shared/components/Tray/TrayMicButton.js
@@ -1,14 +1,11 @@
import React from 'react';
import { TrayButton } from '@custom/shared/components/Tray';
-import { useAudioLevel } from '@custom/shared/hooks/useAudioLevel';
import { ReactComponent as IconMicOff } from '@custom/shared/icons/mic-off-md.svg';
import { ReactComponent as IconMicOn } from '@custom/shared/icons/mic-on-md.svg';
import PropTypes from 'prop-types';
export const TrayMicButton = ({ isMuted, onClick }) => {
- const audioLevel = useAudioLevel('local');
-
return (
{isMuted ? : }
diff --git a/custom/shared/contexts/CallProvider.js b/custom/shared/contexts/CallProvider.js
index 5e8ad4d..763e836 100644
--- a/custom/shared/contexts/CallProvider.js
+++ b/custom/shared/contexts/CallProvider.js
@@ -12,7 +12,9 @@ import React, {
useEffect,
useState,
} from 'react';
+import DailyIframe from '@daily-co/daily-js';
import Bowser from 'bowser';
+import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import {
ACCESS_STATE_LOBBY,
@@ -30,7 +32,12 @@ export const CallProvider = ({
room,
token = '',
subscribeToTracksAutomatically = true,
+ cleanURLOnJoin = false,
}) => {
+ const router = useRouter();
+ const [roomInfo, setRoomInfo] = useState(null);
+ const [enableScreenShare, setEnableScreenShare] = useState(false);
+ const [enableJoinSound, setEnableJoinSound] = useState(true);
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
const [showLocalVideo, setShowLocalVideo] = useState(true);
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
@@ -52,7 +59,14 @@ export const CallProvider = ({
if (!daily) return;
const updateRoomConfigState = async () => {
const roomConfig = await daily.room();
+ const isOob = !!roomConfig.config?.owner_only_broadcast;
+ const owner = roomConfig.tokenConfig?.is_owner;
const config = roomConfig?.config;
+
+ setRoomInfo(roomConfig);
+
+ const fullUI = !isOob || (isOob && owner);
+
if (!config) return;
if (config.exp) {
@@ -76,6 +90,12 @@ export const CallProvider = ({
roomConfig?.tokenConfig?.start_cloud_recording ?? false
);
}
+ setEnableScreenShare(
+ fullUI &&
+ (roomConfig?.tokenConfig?.enable_screenshare ??
+ roomConfig?.config?.enable_screenshare) &&
+ DailyIframe.supportedBrowser().supportsScreenShare
+ );
};
updateRoomConfigState();
}, [state, daily]);
@@ -103,6 +123,15 @@ export const CallProvider = ({
setPreJoinNonAuthorized(requiresPermission && !token);
}, [state, daily, token]);
+ useEffect(() => {
+ if (!daily) return;
+
+ if (cleanURLOnJoin)
+ daily.on('joined-meeting', () => router.replace(`/${room}`));
+
+ return () => daily.off('joined-meeting', () => router.replace(`/${room}`));
+ }, [cleanURLOnJoin, daily, room, router]);
+
return (
{children}
diff --git a/custom/shared/contexts/MediaDeviceProvider.js b/custom/shared/contexts/MediaDeviceProvider.js
index 2e5e6e7..b0b4349 100644
--- a/custom/shared/contexts/MediaDeviceProvider.js
+++ b/custom/shared/contexts/MediaDeviceProvider.js
@@ -1,4 +1,4 @@
-import React, { createContext, useContext } from 'react';
+import React, { createContext, useContext, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useCallState } from './CallProvider';
@@ -12,33 +12,72 @@ export const MediaDeviceProvider = ({ children }) => {
const { localParticipant } = useParticipants();
const {
- cams,
- mics,
- speakers,
camError,
- micError,
- currentDevices,
+ cams,
+ currentCam,
+ currentMic,
+ currentSpeaker,
deviceState,
- setMicDevice,
- setCamDevice,
- setSpeakersDevice,
+ micError,
+ mics,
+ refreshDevices,
+ setCurrentCam,
+ setCurrentMic,
+ setCurrentSpeaker,
+ speakers,
} = useDevices(callObject);
+ const selectCamera = useCallback(
+ async (newCam) => {
+ if (!callObject || newCam.deviceId === currentCam?.deviceId) return;
+ const { camera } = await callObject.setInputDevicesAsync({
+ videoDeviceId: newCam.deviceId,
+ });
+ setCurrentCam(camera);
+ },
+ [callObject, currentCam, setCurrentCam]
+ );
+
+ const selectMic = useCallback(
+ async (newMic) => {
+ if (!callObject || newMic.deviceId === currentMic?.deviceId) return;
+ const { mic } = await callObject.setInputDevicesAsync({
+ audioDeviceId: newMic.deviceId,
+ });
+ setCurrentMic(mic);
+ },
+ [callObject, currentMic, setCurrentMic]
+ );
+
+ const selectSpeaker = useCallback(
+ (newSpeaker) => {
+ if (!callObject || newSpeaker.deviceId === currentSpeaker?.deviceId) return;
+ callObject.setOutputDevice({
+ outputDeviceId: newSpeaker.deviceId,
+ });
+ setCurrentSpeaker(newSpeaker);
+ },
+ [callObject, currentSpeaker, setCurrentSpeaker]
+ );
+
return (
{children}
diff --git a/custom/shared/contexts/ParticipantsProvider.js b/custom/shared/contexts/ParticipantsProvider.js
index 50db416..5df6b7d 100644
--- a/custom/shared/contexts/ParticipantsProvider.js
+++ b/custom/shared/contexts/ParticipantsProvider.js
@@ -1,52 +1,36 @@
-import React, {
+import {
createContext,
useCallback,
useContext,
useEffect,
+ useMemo,
useReducer,
useState,
- useMemo,
} from 'react';
-import {
- useUIState,
- VIEW_MODE_SPEAKER,
-} from '@custom/shared/contexts/UIStateProvider';
-import PropTypes from 'prop-types';
-
-import {
- VIDEO_QUALITY_AUTO,
- VIDEO_QUALITY_BANDWIDTH_SAVER,
- VIDEO_QUALITY_LOW,
- VIDEO_QUALITY_VERY_LOW,
-} from '../constants';
-import { sortByKey } from '../lib/sortByKey';
-
+import { sortByKey } from '@custom/shared/lib/sortByKey';
+import { useNetworkState } from '../hooks/useNetworkState';
import { useCallState } from './CallProvider';
-
+import { useUIState } from './UIStateProvider';
import {
initialParticipantsState,
isLocalId,
- ACTIVE_SPEAKER,
- PARTICIPANT_JOINED,
- PARTICIPANT_LEFT,
- PARTICIPANT_UPDATED,
participantsReducer,
- SWAP_POSITION,
} from './participantsState';
-export const ParticipantsContext = createContext();
+export const ParticipantsContext = createContext(null);
export const ParticipantsProvider = ({ children }) => {
- const { callObject, videoQuality, networkState } = useCallState();
- const [state, dispatch] = useReducer(
- participantsReducer,
- initialParticipantsState
- );
- const { viewMode } = useUIState();
- const [
- participantMarkedForRemoval,
- setParticipantMarkedForRemoval,
- ] = useState(null);
+ const { isMobile, pinnedId, viewMode } = useUIState();
+ const {
+ broadcast,
+ broadcastRole,
+ callObject: daily,
+ videoQuality,
+ } = useCallState();
+ const [state, dispatch] = useReducer(participantsReducer, initialParticipantsState);
+ const [participantMarkedForRemoval, setParticipantMarkedForRemoval] = useState(null);
+
+ const { threshold } = useNetworkState();
/**
* ALL participants (incl. shared screens) in a convenient array
@@ -59,15 +43,12 @@ export const ParticipantsProvider = ({ children }) => {
/**
* Only return participants that should be visible in the call
*/
- const participants = useMemo(() => state.participants, [state.participants]);
-
- /**
- * Array of participant IDs
- */
- const participantIds = useMemo(
- () => participants.map((p) => p.id).join(','),
- [participants]
- );
+ const participants = useMemo(() => {
+ if (broadcast) {
+ return state.participants.filter((p) => p?.isOwner);
+ }
+ return state.participants;
+ }, [broadcast, state.participants]);
/**
* The number of participants, who are not a shared screen
@@ -106,28 +87,26 @@ export const ParticipantsProvider = ({ children }) => {
*/
const currentSpeaker = useMemo(() => {
/**
- * If the activeParticipant is still in the call, return the activeParticipant.
+ * Ensure activeParticipant is still present in the call.
* The activeParticipant only updates to a new active participant so
* if everyone else is muted when AP leaves, the value will be stale.
*/
const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
- if (isPresent) {
- return activeParticipant;
- }
+ const pinned = participants.find((p) => p?.id === pinnedId);
- /**
- * If the activeParticipant has left, calculate the remaining displayable participants
- */
- const displayableParticipants = participants.filter((p) => !p?.isLocal);
+ if (pinned) return pinned;
+
+ const displayableParticipants = participants.filter((p) =>
+ isMobile ? !p?.isLocal && !p?.isScreenshare : !p?.isLocal
+ );
- /**
- * If nobody ever unmuted, return the first participant with a camera on
- * Or, if all cams are off, return the first remote participant
- */
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]
@@ -135,26 +114,56 @@ export const ParticipantsProvider = ({ children }) => {
}
const sorted = displayableParticipants
- .sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
+ .sort(sortByKey('lastActiveDate'))
.reverse();
- const lastActiveSpeaker = sorted?.[0];
+ const fallback = broadcastRole === 'attendee' ? null : localParticipant;
- return lastActiveSpeaker || localParticipant;
- }, [activeParticipant, localParticipant, participants]);
+ return isPresent ? activeParticipant : sorted?.[0] ?? fallback;
+ }, [
+ activeParticipant,
+ broadcastRole,
+ isMobile,
+ localParticipant,
+ participants,
+ pinnedId,
+ ]);
/**
* Screen shares
*/
- const screens = useMemo(
- () => allParticipants.filter(({ isScreenshare }) => isScreenshare),
- [allParticipants]
- );
+ const screens = useMemo(() => state?.screens, [state?.screens]);
/**
* The local participant's name
*/
- const username = callObject?.participants()?.local?.user_name ?? '';
+ const username = daily?.participants()?.local?.user_name ?? '';
+
+ /**
+ * Sets the local participant's name in daily-js
+ * @param name The new username
+ */
+ const setUsername = useCallback(
+ (name) => {
+ daily.setUserName(name);
+ },
+ [daily]
+ );
+
+ const swapParticipantPosition = useCallback((id1, id2) => {
+ /**
+ * Ignore in the following cases:
+ * - id1 and id2 are equal
+ * - one of both ids is not set
+ * - one of both ids is 'local'
+ */
+ if (id1 === id2 || !id1 || !id2 || isLocalId(id1) || isLocalId(id2)) return;
+ dispatch({
+ type: 'SWAP_POSITION',
+ id1,
+ id2,
+ });
+ }, []);
const [muteNewParticipants, setMuteNewParticipants] = useState(false);
@@ -166,95 +175,81 @@ export const ParticipantsProvider = ({ children }) => {
(p) => !p.isLocal && !p.isMicMuted
);
if (!unmutedParticipants.length) return;
- const result = unmutedParticipants.reduce(
- (o, p) => ({ ...o[p.id], setAudio: false }),
- {}
+ daily.updateParticipants(
+ unmutedParticipants.reduce((o, p) => {
+ o[p.id] = {
+ setAudio: false,
+ };
+ return o;
+ }, {})
);
- callObject.updateParticipants(result);
},
- [callObject, localParticipant, participants]
+ [daily, localParticipant, participants]
);
- /**
- * Sets the local participant's name in daily-js
- * @param name The new username
- */
- const setUsername = (name) => {
- callObject.setUserName(name);
- };
-
- const swapParticipantPosition = (id1, id2) => {
- if (id1 === id2 || !id1 || !id2 || isLocalId(id1) || isLocalId(id2)) return;
+ const handleParticipantJoined = useCallback(() => {
dispatch({
- type: SWAP_POSITION,
- id1,
- id2,
+ type: 'JOINED_MEETING',
+ participant: daily.participants().local,
});
- };
+ }, [daily]);
const handleNewParticipantsState = useCallback(
(event = null) => {
switch (event?.action) {
case 'participant-joined':
dispatch({
- type: PARTICIPANT_JOINED,
+ type: 'PARTICIPANT_JOINED',
participant: event.participant,
});
+ if (muteNewParticipants && daily) {
+ daily.updateParticipant(event.participant.session_id, {
+ setAudio: false,
+ });
+ }
break;
case 'participant-updated':
dispatch({
- type: PARTICIPANT_UPDATED,
+ type: 'PARTICIPANT_UPDATED',
participant: event.participant,
});
break;
case 'participant-left':
dispatch({
- type: PARTICIPANT_LEFT,
+ type: 'PARTICIPANT_LEFT',
participant: event.participant,
});
break;
- default:
- break;
}
},
- [dispatch]
+ [daily, dispatch, muteNewParticipants]
);
- /**
- * Start listening for participant changes, when the callObject is set.
- */
useEffect(() => {
- if (!callObject) return false;
+ if (!daily) return;
- console.log('π₯ Participant provider events bound');
+ daily.on('participant-joined', handleParticipantJoined);
+ daily.on('participant-joined', handleNewParticipantsState);
+ daily.on('participant-updated', handleNewParticipantsState);
+ daily.on('participant-left', handleNewParticipantsState);
- const events = [
- 'joined-meeting',
- 'participant-joined',
- 'participant-updated',
- 'participant-left',
- ];
+ return () => {
+ daily.off('participant-joined', handleParticipantJoined);
+ daily.off('participant-joined', handleNewParticipantsState);
+ daily.off('participant-updated', handleNewParticipantsState);
+ daily.off('participant-left', handleNewParticipantsState);
+ };
+ }, [daily, handleNewParticipantsState, handleParticipantJoined]);
- // Use initial state
- handleNewParticipantsState();
+ const participantIds = useMemo(
+ () => participants.map((p) => p.id).join(','),
+ [participants]
+ );
- // Listen for changes in state
- events.forEach((event) => callObject.on(event, handleNewParticipantsState));
-
- // Stop listening for changes in state
- return () =>
- events.forEach((event) =>
- callObject.off(event, handleNewParticipantsState)
- );
- }, [callObject, handleNewParticipantsState]);
-
- /**
- * Change between the simulcast layers based on view / available bandwidth
- */
const setBandWidthControls = useCallback(() => {
- if (!(callObject && callObject.meetingState() === 'joined-meeting')) return;
+ if (!(daily && daily.meetingState() === 'joined-meeting')) return;
- const ids = participantIds.split(',');
+ const ids = participantIds.split(',').filter(Boolean);
const receiveSettings = {};
ids.forEach((id) => {
@@ -262,19 +257,16 @@ export const ParticipantsProvider = ({ children }) => {
if (
// weak or bad network
- ([VIDEO_QUALITY_LOW, VIDEO_QUALITY_VERY_LOW].includes(networkState) &&
- videoQuality === VIDEO_QUALITY_AUTO) ||
+ (['low', 'very-low'].includes(threshold) && videoQuality === 'auto') ||
// Low quality or Bandwidth saver mode enabled
- [VIDEO_QUALITY_BANDWIDTH_SAVER, VIDEO_QUALITY_LOW].includes(
- videoQuality
- )
+ ['bandwidth-saver', 'low'].includes(videoQuality)
) {
receiveSettings[id] = { video: { layer: 0 } };
return;
}
// Speaker view settings based on speaker status or pinned user
- if (viewMode === VIEW_MODE_SPEAKER) {
+ if (viewMode === 'speaker') {
if (currentSpeaker?.id === id) {
receiveSettings[id] = { video: { layer: 2 } };
} else {
@@ -283,13 +275,15 @@ export const ParticipantsProvider = ({ children }) => {
}
// Grid view settings are handled separately in GridView
+ // Mobile view settings are handled separately in MobileCall
});
- callObject.updateReceiveSettings(receiveSettings);
+
+ daily.updateReceiveSettings(receiveSettings);
}, [
currentSpeaker?.id,
- callObject,
- networkState,
+ daily,
participantIds,
+ threshold,
videoQuality,
viewMode,
]);
@@ -299,39 +293,38 @@ export const ParticipantsProvider = ({ children }) => {
}, [setBandWidthControls]);
useEffect(() => {
- if (!callObject) return false;
+ if (!daily) return;
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
/**
* Ignore active-speaker-change events for the local user.
* Our UX doesn't ever highlight the local user as the active speaker.
*/
- const localId = callObject.participants().local.session_id;
+ const localId = daily.participants().local.session_id;
const activeSpeakerId = activeSpeaker?.peerId;
if (localId === activeSpeakerId) return;
dispatch({
- type: ACTIVE_SPEAKER,
+ type: 'ACTIVE_SPEAKER',
id: activeSpeakerId,
});
};
- callObject.on('active-speaker-change', handleActiveSpeakerChange);
+ daily.on('active-speaker-change', handleActiveSpeakerChange);
return () =>
- callObject.off('active-speaker-change', handleActiveSpeakerChange);
- }, [callObject]);
+ daily.off('active-speaker-change', handleActiveSpeakerChange);
+ }, [daily]);
return (
{
);
};
-ParticipantsProvider.propTypes = {
- children: PropTypes.node,
-};
-
-export const useParticipants = () => useContext(ParticipantsContext);
+export const useParticipants = () => useContext(ParticipantsContext);
\ No newline at end of file
diff --git a/custom/shared/contexts/TracksProvider.js b/custom/shared/contexts/TracksProvider.js
index f61ffea..d4f17e5 100644
--- a/custom/shared/contexts/TracksProvider.js
+++ b/custom/shared/contexts/TracksProvider.js
@@ -1,37 +1,29 @@
-/* global rtcpeers */
-
-import React, {
+import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
+ useRef,
+ useState,
} from 'react';
-
+import { sortByKey } from '@custom/shared/lib/sortByKey';
import deepEqual from 'fast-deep-equal';
-import PropTypes from 'prop-types';
-import { useDeepCompareEffect } from 'use-deep-compare';
-import { sortByKey } from '../lib/sortByKey';
+import { useDeepCompareCallback } from 'use-deep-compare';
+
import { useCallState } from './CallProvider';
import { useParticipants } from './ParticipantsProvider';
-import { isLocalId, isScreenId } from './participantsState';
-import {
- initialTracksState,
- REMOVE_TRACKS,
- TRACK_STARTED,
- TRACK_STOPPED,
- TRACK_VIDEO_UPDATED,
- TRACK_AUDIO_UPDATED,
- tracksReducer,
-} from './tracksState';
+import { useUIState } from './UIStateProvider';
+import { getScreenId, isLocalId, isScreenId } from './participantsState';
+import { initialTracksState, tracksReducer } from './tracksState';
/**
- * Maximum amount of concurrently subscribed most recent speakers.
+ * Maximum amount of concurrently subscribed or staged most recent speakers.
*/
-const MAX_RECENT_SPEAKER_COUNT = 6;
+export const MAX_RECENT_SPEAKER_COUNT = 8;
/**
- * Threshold up to which all videos will be subscribed.
+ * Threshold up to which all cams will be subscribed to or staged.
* If the remote participant count passes this threshold,
* cam subscriptions are defined by UI view modes.
*/
@@ -40,15 +32,17 @@ const SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD = 9;
const TracksContext = createContext(null);
export const TracksProvider = ({ children }) => {
- const { callObject, subscribeToTracksAutomatically } = useCallState();
+ const { callObject: daily, optimizeLargeCalls } = useCallState();
const { participants } = useParticipants();
+ const { viewMode } = useUIState();
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
+ const [maxCamSubscriptions, setMaxCamSubscriptions] = useState(null);
const recentSpeakerIds = useMemo(
() =>
participants
- .filter((p) => Boolean(p.lastActiveDate) && !p.isLocal)
- .sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
+ .filter((p) => Boolean(p.lastActiveDate))
+ .sort(sortByKey('lastActiveDate'))
.slice(-MAX_RECENT_SPEAKER_COUNT)
.map((p) => p.id)
.reverse(),
@@ -62,40 +56,51 @@ export const TracksProvider = ({ children }) => {
const subscribeToCam = useCallback(
(id) => {
- // Ignore undefined, local or screenshare.
+ /**
+ * Ignore undefined, local or screenshare.
+ */
if (!id || isLocalId(id) || isScreenId(id)) return;
- callObject.updateParticipant(id, {
+ daily.updateParticipant(id, {
setSubscribedTracks: { video: true },
});
},
- [callObject]
+ [daily]
);
/**
* Updates cam subscriptions based on passed subscribedIds and stagedIds.
- * For ids not provided, cam tracks will be unsubscribed from
+ * For ids not provided, cam tracks will be unsubscribed from.
+ *
+ * @param subscribedIds Participant ids whose cam tracks should be subscribed to.
+ * @param stagedIds Participant ids whose cam tracks should be staged.
*/
const updateCamSubscriptions = useCallback(
(subscribedIds, stagedIds = []) => {
- if (!callObject) return;
+ if (!daily) return;
// If total number of remote participants is less than a threshold, simply
// stage all remote cams that aren't already marked for subscription.
// Otherwise, honor the provided stagedIds, with recent speakers appended
// who aren't already marked for subscription.
- const stagedIdsFiltered =
+ if (
remoteParticipantIds.length <= SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD
- ? remoteParticipantIds.filter((id) => !subscribedIds.includes(id))
- : [
- ...stagedIds,
- ...recentSpeakerIds.filter((id) => !subscribedIds.includes(id)),
- ];
+ ) {
+ stagedIds = remoteParticipantIds.filter(
+ (id) => !subscribedIds.includes(id)
+ );
+ } else {
+ if (viewMode !== 'grid') {
+ stagedIds.push(
+ ...recentSpeakerIds.filter((id) => !subscribedIds.includes(id))
+ );
+ }
+ }
// Assemble updates to get to desired cam subscriptions
const updates = remoteParticipantIds.reduce((u, id) => {
let desiredSubscription;
const currentSubscription =
- callObject.participants()?.[id]?.tracks?.video?.subscribed;
+ daily.participants()?.[id]?.tracks?.video?.subscribed;
// Ignore undefined, local or screenshare participant ids
if (!id || isLocalId(id) || isScreenId(id)) return u;
@@ -104,7 +109,7 @@ export const TracksProvider = ({ children }) => {
// subscribed, staged, or unsubscribed
if (subscribedIds.includes(id)) {
desiredSubscription = true;
- } else if (stagedIdsFiltered.includes(id)) {
+ } else if (stagedIds.includes(id)) {
desiredSubscription = 'staged';
} else {
desiredSubscription = false;
@@ -116,9 +121,6 @@ export const TracksProvider = ({ children }) => {
u[id] = {
setSubscribedTracks: {
- audio: true,
- screenAudio: true,
- screenVideo: true,
video: desiredSubscription,
},
};
@@ -126,110 +128,126 @@ export const TracksProvider = ({ children }) => {
}, {});
if (Object.keys(updates).length === 0) return;
- callObject.updateParticipants(updates);
+ daily.updateParticipants(updates);
},
- [callObject, remoteParticipantIds, recentSpeakerIds]
+ [daily, remoteParticipantIds, recentSpeakerIds, viewMode]
);
+ /**
+ * Automatically update audio subscriptions.
+ */
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) {
- trackStoppedQueue.push([participant, track]);
- }
- };
-
- const handleParticipantLeft = ({ participant }) => {
- dispatch({
- type: REMOVE_TRACKS,
- participant,
- });
- };
-
- const joinedSubscriptionQueue = [];
-
- const handleParticipantJoined = ({ participant }) => {
- joinedSubscriptionQueue.push(participant.session_id);
- };
-
- const joinBatchInterval = setInterval(async () => {
- if (!joinedSubscriptionQueue.length) return;
- const ids = joinedSubscriptionQueue.splice(0);
- const participants = callObject.participants();
- const topology = (await callObject.getNetworkTopology())?.topology;
- const updates = ids.reduce((o, id) => {
- if (!participants?.[id]?.tracks?.audio?.subscribed) {
- o[id] = {
+ if (!daily) return;
+ /**
+ * A little throttling as we want daily-js to have some room to breathe βΊοΈ
+ */
+ const timeout = setTimeout(() => {
+ const participants = daily.participants();
+ const updates = remoteParticipantIds.reduce((u, id) => {
+ // Ignore undefined, local or screenshare participant ids
+ if (!id || isLocalId(id) || isScreenId(id)) return u;
+ const isSpeaker = recentSpeakerIds.includes(id);
+ const hasSubscribed = participants[id]?.tracks?.audio?.subscribed;
+ const shouldSubscribe = optimizeLargeCalls ? isSpeaker : true;
+ /**
+ * In optimized calls:
+ * - subscribe to speakers we're not subscribed to, yet
+ * - unsubscribe from non-speakers we're subscribed to
+ * In non-optimized calls:
+ * - subscribe to all who we're not to subscribed to, yet
+ */
+ if (
+ (!hasSubscribed && shouldSubscribe) ||
+ (hasSubscribed && !shouldSubscribe)
+ ) {
+ u[id] = {
setSubscribedTracks: {
- audio: true,
- screenAudio: true,
- screenVideo: true,
+ audio: shouldSubscribe,
},
};
}
- if (topology === 'peer') {
- o[id] = { setSubscribedTracks: true };
- }
- return o;
+ return u;
}, {});
-
- if (!subscribeToTracksAutomatically && Object.keys(updates).length0) {
- callObject.updateParticipants(updates);
- }
+ if (Object.keys(updates).length === 0) return;
+ daily.updateParticipants(updates);
}, 100);
-
- callObject.on('track-started', handleTrackStarted);
- callObject.on('track-stopped', handleTrackStopped);
- callObject.on('participant-joined', handleParticipantJoined);
- callObject.on('participant-left', handleParticipantLeft);
return () => {
- clearInterval(joinBatchInterval);
- clearInterval(trackStoppedBatchInterval);
- callObject.off('track-started', handleTrackStarted);
- callObject.off('track-stopped', handleTrackStopped);
- callObject.off('participant-joined', handleParticipantJoined);
- callObject.off('participant-left', handleParticipantLeft);
+ clearTimeout(timeout);
};
- }, [callObject, subscribeToTracksAutomatically]);
+ }, [daily, optimizeLargeCalls, recentSpeakerIds, remoteParticipantIds]);
- useDeepCompareEffect(() => {
- if (!callObject) return;
+ /**
+ * Notify user when pushed out of recent speakers queue.
+ */
+ const showMutedMessage = useRef(false);
+ useEffect(() => {
+ if (!daily || !optimizeLargeCalls) return;
- const handleParticipantUpdated = ({ participant }) => {
+ if (recentSpeakerIds.some((id) => isLocalId(id))) {
+ showMutedMessage.current = true;
+ return;
+ }
+ if (showMutedMessage.current && daily.participants().local.audio) {
+ daily.setLocalAudio(false);
+ showMutedMessage.current = false;
+ }
+ }, [daily, optimizeLargeCalls, recentSpeakerIds]);
+
+ const trackStoppedQueue = useRef([]);
+ useEffect(() => {
+ const trackStoppedBatchInterval = setInterval(() => {
+ if (!trackStoppedQueue.current.length) return;
+ dispatch({
+ type: 'TRACKS_STOPPED',
+ items: trackStoppedQueue.current.splice(
+ 0,
+ trackStoppedQueue.current.length
+ ),
+ });
+ }, 3000);
+ return () => {
+ clearInterval(trackStoppedBatchInterval);
+ };
+ }, []);
+
+ const handleTrackStarted = useCallback(({ 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.current.findIndex(
+ ([p, t]) =>
+ p.session_id === participant.session_id && t.kind === track.kind
+ );
+ if (stoppingIdx >= 0) {
+ trackStoppedQueue.current.splice(stoppingIdx, 1);
+ }
+ dispatch({
+ type: 'TRACK_STARTED',
+ participant,
+ track,
+ });
+ }, []);
+
+ const handleTrackStopped = useCallback(({ participant, track }) => {
+ if (participant) {
+ trackStoppedQueue.current.push([participant, track]);
+ }
+ }, []);
+
+ const handleParticipantJoined = useCallback(({ participant }) => {
+ joinedSubscriptionQueue.current.push(participant.session_id);
+ }, []);
+
+ const handleParticipantUpdated = useDeepCompareCallback(
+ ({ participant }) => {
const hasAudioChanged =
// State changed
- participant.tracks.audio?.state !==
- state.audioTracks?.[participant.user_id]?.state ||
+ participant.tracks.audio.state !==
+ state.audioTracks?.[participant.user_id]?.state ||
+ // Screen state changed
+ participant.tracks.screenAudio.state !==
+ state.audioTracks?.[getScreenId(participant.user_id)]?.state ||
// Off/blocked reason changed
!deepEqual(
{
@@ -237,14 +255,14 @@ export const TracksProvider = ({ children }) => {
...(participant.tracks.audio?.off ?? {}),
},
{
- ...(state.audioTracks?.[participant.user_id]?.blocked ?? {}),
- ...(state.audioTracks?.[participant.user_id]?.off ?? {}),
+ ...(state.audioTracks?.[participant.user_id].blocked ?? {}),
+ ...(state.audioTracks?.[participant.user_id].off ?? {}),
}
);
const hasVideoChanged =
// State changed
participant.tracks.video?.state !==
- state.videoTracks?.[participant.user_id]?.state ||
+ state.videoTracks?.[participant.user_id]?.state ||
// Off/blocked reason changed
!deepEqual(
{
@@ -260,7 +278,7 @@ export const TracksProvider = ({ children }) => {
if (hasAudioChanged) {
// Update audio track state
dispatch({
- type: TRACK_AUDIO_UPDATED,
+ type: 'UPDATE_AUDIO_TRACK',
participant,
});
}
@@ -268,27 +286,92 @@ export const TracksProvider = ({ children }) => {
if (hasVideoChanged) {
// Update video track state
dispatch({
- type: TRACK_VIDEO_UPDATED,
+ type: 'UPDATE_VIDEO_TRACK',
participant,
});
}
- };
+ },
+ [state.audioTracks, state.videoTracks]
+ );
- callObject.on('participant-updated', handleParticipantUpdated);
+ const handleParticipantLeft = useCallback(({ participant }) => {
+ dispatch({
+ type: 'REMOVE_TRACKS',
+ participant,
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!daily) return;
+
+ daily.on('track-started', handleTrackStarted);
+ daily.on('track-stopped', handleTrackStopped);
+ daily.on('participant-joined', handleParticipantJoined);
+ daily.on('participant-updated', handleParticipantUpdated);
+ daily.on('participant-left', handleParticipantLeft);
return () => {
- callObject.off('participant-updated', handleParticipantUpdated);
+ daily.off('track-started', handleTrackStarted);
+ daily.off('track-stopped', handleTrackStopped);
+ daily.off('participant-joined', handleParticipantJoined);
+ daily.off('participant-updated', handleParticipantUpdated);
+ daily.off('participant-left', handleParticipantLeft);
};
- }, [callObject, state.audioTracks, state.videoTracks]);
+ }, [
+ daily,
+ handleParticipantJoined,
+ handleParticipantLeft,
+ handleParticipantUpdated,
+ handleTrackStarted,
+ handleTrackStopped
+ ]);
+
+ const joinedSubscriptionQueue = useRef([]);
+ useEffect(() => {
+ if (!daily) return;
+ const joinBatchInterval = setInterval(async () => {
+ if (!joinedSubscriptionQueue.current.length) return;
+ const ids = joinedSubscriptionQueue.current.splice(0);
+ const participants = daily.participants();
+ const topology = (await daily.getNetworkTopology())?.topology;
+ const updates = ids.reduce(
+ (o, id) => {
+ if (!participants?.[id]?.tracks?.audio?.subscribed) {
+ o[id] = {
+ setSubscribedTracks: {
+ screenAudio: true,
+ screenVideo: true,
+ },
+ };
+ }
+ if (topology === 'peer') {
+ o[id] = { setSubscribedTracks: true };
+ }
+ return o;
+ },
+ {}
+ );
+ if (Object.keys(updates).length === 0) return;
+ daily.updateParticipants(updates);
+ }, 100);
+ return () => {
+ clearInterval(joinBatchInterval);
+ };
+ }, [daily]);
+
+ useEffect(() => {
+ if (optimizeLargeCalls) {
+ setMaxCamSubscriptions(30);
+ }
+ }, [optimizeLargeCalls]);
return (
{children}
@@ -296,8 +379,4 @@ export const TracksProvider = ({ children }) => {
);
};
-TracksProvider.propTypes = {
- children: PropTypes.node,
-};
-
-export const useTracks = () => useContext(TracksContext);
+export const useTracks = () => useContext(TracksContext);
\ No newline at end of file
diff --git a/custom/shared/contexts/UIStateProvider.js b/custom/shared/contexts/UIStateProvider.js
index d2e02d1..cf283d5 100644
--- a/custom/shared/contexts/UIStateProvider.js
+++ b/custom/shared/contexts/UIStateProvider.js
@@ -21,6 +21,7 @@ export const UIStateProvider = ({
children,
}) => {
const [pinnedId, setPinnedId] = useState(null);
+ const [isMobile, setIsMobile] = useState(false);
const [preferredViewMode, setPreferredViewMode] = useState(VIEW_MODE_SPEAKER);
const [viewMode, setViewMode] = useState(preferredViewMode);
const [isShowingScreenshare, setIsShowingScreenshare] = useState(false);
@@ -28,6 +29,21 @@ export const UIStateProvider = ({
const [showAside, setShowAside] = useState();
const [activeModals, setActiveModals] = useState({});
const [customCapsule, setCustomCapsule] = useState();
+ const [showAutoplayFailedModal, setShowAutoplayFailedModal] = useState(false);
+
+
+ /**
+ * Decide on view mode based on input conditions.
+ */
+ useEffect(() => {
+ if (isMobile) {
+ setViewMode(VIEW_MODE_MOBILE);
+ } else if (pinnedId || isShowingScreenshare) {
+ setViewMode(VIEW_MODE_SPEAKER);
+ } else {
+ setViewMode(preferredViewMode);
+ }
+ }, [pinnedId, isMobile, isShowingScreenshare, preferredViewMode]);
const openModal = useCallback((modalName) => {
setActiveModals((prevState) => ({
@@ -87,6 +103,10 @@ export const UIStateProvider = ({
setShowParticipantsBar,
customCapsule,
setCustomCapsule,
+ showAutoplayFailedModal,
+ setShowAutoplayFailedModal,
+ isMobile,
+ setIsMobile,
}}
>
{children}
diff --git a/custom/shared/contexts/participantsState.js b/custom/shared/contexts/participantsState.js
index 1b2a47c..6829d2e 100644
--- a/custom/shared/contexts/participantsState.js
+++ b/custom/shared/contexts/participantsState.js
@@ -1,19 +1,6 @@
-/**
- * Call state is comprised of:
- * - "Call items" (inputs to the call, i.e. participants or shared screens)
- * - UI state that depends on call items (for now, just whether to show "click allow" message)
- *
- * Call items are keyed by id:
- * - "local" for the current participant
- * - 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,
- DEVICE_STATE_LOADING,
-} from './useDevices';
+
+import { MAX_RECENT_SPEAKER_COUNT } from './TracksProvider';
const initialParticipantsState = {
lastPendingUnknownActiveSpeaker: null,
@@ -22,7 +9,6 @@ const initialParticipantsState = {
camMutedByHost: false,
hasNameSet: false,
id: 'local',
- user_id: '',
isActiveSpeaker: false,
isCamMuted: false,
isLoading: true,
@@ -34,120 +20,20 @@ const initialParticipantsState = {
lastActiveDate: null,
micMutedByHost: false,
name: '',
+ sessionId: '',
},
],
screens: [],
};
-// --- Derived data ---
+// --- Reducer and helpers --
-function getId(participant) {
- return participant.local ? 'local' : participant.user_id;
-}
-
-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 getNewParticipant(participant) {
- const id = getId(participant);
-
- const { local } = participant;
- const { audio, video } = participant.tracks;
-
- return {
- camMutedByHost: video?.off?.byRemoteRequest,
- hasNameSet: !!participant.user_name,
- id,
- user_id: participant.user_id,
- isActiveSpeaker: false,
- 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,
- isScreenshare: false,
- lastActiveDate: null,
- micMutedByHost: audio?.off?.byRemoteRequest,
- name: participant.user_name,
- };
-}
-
-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,
- user_id: participant.user_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,
- id: getScreenId(id),
- isLoading: false,
- isLocal: participant.local,
- isScreenshare: true,
- lastActiveDate: null,
- name: participant.user_name,
- };
-}
-
-// --- Actions ---
-
-const ACTIVE_SPEAKER = 'ACTIVE_SPEAKER';
-const PARTICIPANT_JOINED = 'PARTICIPANT_JOINED';
-const PARTICIPANT_UPDATED = 'PARTICIPANT_UPDATED';
-const PARTICIPANT_LEFT = 'PARTICIPANT_LEFT';
-const SWAP_POSITION = 'SWAP_POSITION';
-
-// --- Reducer --
-
-function participantsReducer(prevState, action) {
+function participantsReducer(
+ prevState,
+ action
+) {
switch (action.type) {
- case ACTIVE_SPEAKER: {
+ case 'ACTIVE_SPEAKER': {
const { participants, ...state } = prevState;
if (!action.id)
return {
@@ -161,9 +47,9 @@ function participantsReducer(prevState, action) {
lastPendingUnknownActiveSpeaker: isParticipantKnown
? null
: {
- date,
- id: action.id,
- },
+ date,
+ id: action.id,
+ },
participants: participants.map((p) => ({
...p,
isActiveSpeaker: p.id === action.id,
@@ -171,7 +57,19 @@ function participantsReducer(prevState, action) {
})),
};
}
- case PARTICIPANT_JOINED: {
+ case 'JOINED_MEETING': {
+ const localItem = getNewParticipant(action.participant);
+
+ const participants = [...prevState.participants].map((p) =>
+ p.isLocal ? localItem : p
+ );
+
+ return {
+ ...prevState,
+ participants,
+ };
+ }
+ case 'PARTICIPANT_JOINED': {
const item = getNewParticipant(action.participant);
const participants = [...prevState.participants];
@@ -197,6 +95,14 @@ function participantsReducer(prevState, action) {
}
}
+ // Mark new participant as active speaker, for quicker audio subscription
+ if (
+ !item.isMicMuted &&
+ participants.length <= MAX_RECENT_SPEAKER_COUNT + 1 // + 1 for local participant
+ ) {
+ item.lastActiveDate = new Date();
+ }
+
// Participant is sharing screen
if (action.participant.screen) {
screens.push(getScreenItem(action.participant));
@@ -211,7 +117,7 @@ function participantsReducer(prevState, action) {
screens,
};
}
- case PARTICIPANT_UPDATED: {
+ case 'PARTICIPANT_UPDATED': {
const item = getUpdatedParticipant(
action.participant,
prevState.participants
@@ -221,6 +127,10 @@ function participantsReducer(prevState, action) {
const participants = [...prevState.participants];
const idx = participants.findIndex((p) => p.id === id);
+ if (!item.isMicMuted && participants[idx].isMicMuted) {
+ // Participant unmuted mic
+ item.lastActiveDate = new Date();
+ }
participants[idx] = item;
const screens = [...prevState.screens];
@@ -249,7 +159,7 @@ function participantsReducer(prevState, action) {
return newState;
}
- case PARTICIPANT_LEFT: {
+ case 'PARTICIPANT_LEFT': {
const id = getId(action.participant);
const screenId = getScreenId(id);
@@ -259,7 +169,7 @@ function participantsReducer(prevState, action) {
screens: [...prevState.screens].filter((s) => s.id !== screenId),
};
}
- case SWAP_POSITION: {
+ case 'SWAP_POSITION': {
const participants = [...prevState.participants];
if (!action.id1 || !action.id2) return prevState;
const idx1 = participants.findIndex((p) => p.id === action.id1);
@@ -278,16 +188,98 @@ function participantsReducer(prevState, action) {
}
}
+function getNewParticipant(participant) {
+ const id = getId(participant);
+
+ const { local } = participant;
+ const { audio, video } = participant.tracks;
+
+ return {
+ camMutedByHost: video?.off?.byRemoteRequest,
+ hasNameSet: !!participant.user_name,
+ id,
+ isActiveSpeaker: false,
+ isCamMuted: video?.state === 'off' || video?.state === 'blocked',
+ isLoading: audio?.state === 'loading' || video?.state === 'loading',
+ isLocal: local,
+ isMicMuted: audio?.state === 'off' || audio?.state === 'blocked',
+ isOwner: !!participant.owner,
+ isRecording: !!participant.record,
+ isScreenshare: false,
+ lastActiveDate: null,
+ micMutedByHost: audio?.off?.byRemoteRequest,
+ name: participant.user_name,
+ sessionId: participant.session_id,
+ };
+}
+
+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 === 'off' || video?.state === 'blocked',
+ isLoading: audio?.state === 'loading' || video?.state === 'loading',
+ isLocal: local,
+ isMicMuted: audio?.state === 'off' || audio?.state === 'blocked',
+ isOwner: !!participant.owner,
+ isRecording: !!participant.record,
+ micMutedByHost: audio?.off?.byRemoteRequest,
+ name: participant.user_name,
+ sessionId: participant.session_id,
+ };
+}
+
+function getScreenItem(participant) {
+ const id = getId(participant);
+ return {
+ hasNameSet: null,
+ id: getScreenId(id),
+ isLoading: false,
+ isLocal: participant.local,
+ isScreenshare: true,
+ lastActiveDate: null,
+ name: participant.user_name,
+ sessionId: participant.session_id,
+ };
+}
+
+// --- Derived data ---
+
+function getId(participant) {
+ return participant.local ? 'local' : participant.session_id;
+}
+
+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');
+}
+
export {
- ACTIVE_SPEAKER,
getId,
getScreenId,
+ initialParticipantsState,
isLocalId,
isScreenId,
participantsReducer,
- initialParticipantsState,
- PARTICIPANT_JOINED,
- PARTICIPANT_LEFT,
- PARTICIPANT_UPDATED,
- SWAP_POSITION,
-};
+};
\ No newline at end of file
diff --git a/custom/shared/contexts/tracksState.js b/custom/shared/contexts/tracksState.js
index 60b4383..a07ae64 100644
--- a/custom/shared/contexts/tracksState.js
+++ b/custom/shared/contexts/tracksState.js
@@ -1,31 +1,18 @@
-/**
- * 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';
-export const initialTracksState = {
+const initialTracksState = {
audioTracks: {},
videoTracks: {},
};
-// --- Actions ---
-
-export const TRACK_STARTED = 'TRACK_STARTED';
-export const TRACK_STOPPED = 'TRACK_STOPPED';
-export const TRACK_VIDEO_UPDATED = 'TRACK_VIDEO_UPDATED';
-export const TRACK_AUDIO_UPDATED = 'TRACK_AUDIO_UPDATED';
-export const REMOVE_TRACKS = 'REMOVE_TRACKS';
-
// --- Reducer and helpers --
-export function tracksReducer(prevState, action) {
+function tracksReducer(
+ prevState,
+ action
+) {
switch (action.type) {
- case TRACK_STARTED: {
+ case 'TRACK_STARTED': {
const id = getId(action.participant);
const screenId = getScreenId(id);
@@ -63,17 +50,15 @@ export function tracksReducer(prevState, action) {
},
};
}
-
- case TRACK_STOPPED: {
+ case 'TRACKS_STOPPED': {
const { audioTracks, videoTracks } = prevState;
const newAudioTracks = { ...audioTracks };
const newVideoTracks = { ...videoTracks };
- action.items.forEach(([participant, track]) => {
+ for (const [participant, track] of action.items) {
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
@@ -88,16 +73,16 @@ export function tracksReducer(prevState, action) {
newVideoTracks[screenId] = participant.tracks.screenVideo;
}
}
- });
+ }
return {
audioTracks: newAudioTracks,
videoTracks: newVideoTracks,
};
}
-
- case TRACK_AUDIO_UPDATED: {
+ case 'UPDATE_AUDIO_TRACK': {
const id = getId(action.participant);
+ const screenId = getScreenId(id);
if (action.participant?.local) {
// Ignore local audio from mic and screen share
return prevState;
@@ -105,14 +90,14 @@ export function tracksReducer(prevState, action) {
const newAudioTracks = {
...prevState.audioTracks,
[id]: action.participant.tracks.audio,
+ [screenId]: action.participant.tracks.screenAudio,
};
return {
...prevState,
audioTracks: newAudioTracks,
};
}
-
- case TRACK_VIDEO_UPDATED: {
+ case 'UPDATE_VIDEO_TRACK': {
const id = getId(action.participant);
const newVideoTracks = {
...prevState.videoTracks,
@@ -123,8 +108,7 @@ export function tracksReducer(prevState, action) {
videoTracks: newVideoTracks,
};
}
-
- case REMOVE_TRACKS: {
+ case 'REMOVE_TRACKS': {
const { audioTracks, videoTracks } = prevState;
const id = getId(action.participant);
const screenId = getScreenId(id);
@@ -139,8 +123,9 @@ export function tracksReducer(prevState, action) {
videoTracks,
};
}
-
default:
throw new Error();
}
}
+
+export { initialTracksState, tracksReducer };
\ No newline at end of file
diff --git a/custom/shared/contexts/useCallMachine.js b/custom/shared/contexts/useCallMachine.js
index f691796..979691b 100644
--- a/custom/shared/contexts/useCallMachine.js
+++ b/custom/shared/contexts/useCallMachine.js
@@ -29,6 +29,7 @@ export const CALL_STATE_REDIRECTING = 'redirecting';
export const CALL_STATE_NOT_FOUND = 'not-found';
export const CALL_STATE_NOT_ALLOWED = 'not-allowed';
export const CALL_STATE_AWAITING_ARGS = 'awaiting-args';
+export const CALL_STATE_NOT_SECURE = 'not-secure';
export const useCallMachine = ({
domain,
@@ -78,10 +79,17 @@ export const useCallMachine = ({
const join = useCallback(
async (callObject) => {
setState(CALL_STATE_JOINING);
+ const dailyRoomInfo = await callObject.room();
+
+ // Force mute clients when joining a call with experimental_optimize_large_calls enabled.
+ if (dailyRoomInfo?.config?.experimental_optimize_large_calls) {
+ callObject.setLocalAudio(false);
+ }
+
await callObject.join({ subscribeToTracksAutomatically, token, url });
setState(CALL_STATE_JOINED);
},
- [token, subscribeToTracksAutomatically, url]
+ [room, token, subscribeToTracksAutomatically, url]
);
/**
@@ -182,6 +190,15 @@ export const useCallMachine = ({
useEffect(() => {
if (daily || !url || state !== CALL_STATE_READY) return;
+ if (
+ location.protocol !== 'https:' &&
+ // We want to still allow local development.
+ !['localhost'].includes(location.hostname)
+ ) {
+ setState('not-secure');
+ return;
+ }
+
console.log('π Creating call object');
const co = DailyIframe.createCallObject({
@@ -200,7 +217,7 @@ export const useCallMachine = ({
* Listen for changes in the participant's access state
*/
useEffect(() => {
- if (!daily) return false;
+ if (!daily) return;
daily.on('access-state-updated', handleAccessStateUpdated);
return () => daily.off('access-state-updated', handleAccessStateUpdated);
diff --git a/custom/shared/contexts/useDevices.js b/custom/shared/contexts/useDevices.js
index 9d330f2..ee4362a 100644
--- a/custom/shared/contexts/useDevices.js
+++ b/custom/shared/contexts/useDevices.js
@@ -15,7 +15,9 @@ export const DEVICE_STATE_SENDABLE = 'sendable';
export const useDevices = (callObject) => {
const [deviceState, setDeviceState] = useState(DEVICE_STATE_LOADING);
- const [currentDevices, setCurrentDevices] = useState(null);
+ const [currentCam, setCurrentCam] = useState(null);
+ const [currentMic, setCurrentMic] = useState(null);
+ const [currentSpeaker, setCurrentSpeaker] = useState(null);
const [cams, setCams] = useState([]);
const [mics, setMics] = useState([]);
@@ -38,6 +40,10 @@ export const useDevices = (callObject) => {
const { camera, mic, speaker } = await callObject.getInputDevices();
+ setCurrentCam(camera ?? null);
+ setCurrentMic(mic ?? null);
+ setCurrentSpeaker(speaker ?? null);
+
const [defaultCam, ...videoDevices] = devices.filter(
(d) => d.kind === 'videoinput' && d.deviceId !== ''
);
@@ -66,12 +72,6 @@ export const useDevices = (callObject) => {
].filter(Boolean)
);
- setCurrentDevices({
- camera,
- mic,
- speaker,
- });
-
console.log(`Current cam: ${camera.label}`);
console.log(`Current mic: ${mic.label}`);
console.log(`Current speakers: ${speaker.label}`);
@@ -125,31 +125,26 @@ export const useDevices = (callObject) => {
const handleParticipantUpdated = useCallback(
({ participant }) => {
- if (!callObject || !participant.local) return;
+ if (!callObject || deviceState === 'not-supported' || !participant.local) return;
- setDeviceState((prevState) => {
- if (prevState === DEVICE_STATE_NOT_SUPPORTED) return prevState;
- switch (participant?.tracks.video.state) {
- case DEVICE_STATE_BLOCKED:
- updateDeviceErrors();
- return DEVICE_STATE_ERROR;
- case DEVICE_STATE_OFF:
- case DEVICE_STATE_PLAYABLE:
- if (prevState === DEVICE_STATE_GRANTED) {
- return prevState;
- }
- updateDeviceState();
- return DEVICE_STATE_GRANTED;
- default:
- return prevState;
- }
- });
+ switch (participant?.tracks.video.state) {
+ case DEVICE_STATE_BLOCKED:
+ setDeviceState(DEVICE_STATE_ERROR);
+ break;
+ case DEVICE_STATE_OFF:
+ case DEVICE_STATE_PLAYABLE:
+ updateDeviceState();
+ setDeviceState(DEVICE_STATE_GRANTED);
+ break;
+ }
+
+ updateDeviceErrors();
},
- [callObject, updateDeviceState, updateDeviceErrors]
+ [callObject, deviceState, updateDeviceErrors, updateDeviceState]
);
useEffect(() => {
- if (!callObject) return false;
+ if (!callObject) return;
/**
If the user is slow to allow access, we'll update the device state
@@ -169,6 +164,7 @@ export const useDevices = (callObject) => {
updateDeviceState();
};
+ updateDeviceState();
callObject.on('joining-meeting', handleJoiningMeeting);
callObject.on('joined-meeting', handleJoinedMeeting);
callObject.on('participant-updated', handleParticipantUpdated);
@@ -180,74 +176,8 @@ export const useDevices = (callObject) => {
};
}, [callObject, handleParticipantUpdated, updateDeviceState]);
- const setCamDevice = useCallback(
- async (newCam, useLocalStorage = true) => {
- if (!callObject || newCam.deviceId === currentDevices?.camera?.deviceId) {
- return;
- }
-
- console.log(`π Changing camera device to: ${newCam.label}`);
-
- if (useLocalStorage) {
- localStorage.setItem('defaultCamId', newCam.deviceId);
- }
-
- await callObject.setInputDevicesAsync({
- videoDeviceId: newCam.deviceId,
- });
-
- setCurrentDevices((prev) => ({ ...prev, camera: newCam }));
- },
- [callObject, currentDevices]
- );
-
- const setMicDevice = useCallback(
- async (newMic, useLocalStorage = true) => {
- if (!callObject || newMic.deviceId === currentDevices?.mic?.deviceId) {
- return;
- }
-
- console.log(`π Changing mic device to: ${newMic.label}`);
-
- if (useLocalStorage) {
- localStorage.setItem('defaultMicId', newMic.deviceId);
- }
-
- await callObject.setInputDevicesAsync({
- audioDeviceId: newMic.deviceId,
- });
-
- setCurrentDevices((prev) => ({ ...prev, mic: newMic }));
- },
- [callObject, currentDevices]
- );
-
- const setSpeakersDevice = useCallback(
- async (newSpeakers, useLocalStorage = true) => {
- if (
- !callObject ||
- newSpeakers.deviceId === currentDevices?.speaker?.deviceId
- ) {
- return;
- }
-
- console.log(`Changing speakers device to: ${newSpeakers.label}`);
-
- if (useLocalStorage) {
- localStorage.setItem('defaultSpeakersId', newSpeakers.deviceId);
- }
-
- callObject.setOutputDevice({
- outputDeviceId: newSpeakers.deviceId,
- });
-
- setCurrentDevices((prev) => ({ ...prev, speaker: newSpeakers }));
- },
- [callObject, currentDevices]
- );
-
useEffect(() => {
- if (!callObject) return false;
+ if (!callObject) return;
console.log('π» Device provider events bound');
@@ -313,16 +243,19 @@ export const useDevices = (callObject) => {
}, [callObject, updateDeviceErrors]);
return {
- cams,
- mics,
- speakers,
camError,
- micError,
- currentDevices,
+ cams,
+ currentCam,
+ currentMic,
+ currentSpeaker,
deviceState,
- setCamDevice,
- setMicDevice,
- setSpeakersDevice,
+ micError,
+ mics,
+ refreshDevices: updateDeviceState,
+ setCurrentCam,
+ setCurrentMic,
+ setCurrentSpeaker,
+ speakers,
};
};
diff --git a/custom/shared/hooks/useActiveSpeaker.js b/custom/shared/hooks/useActiveSpeaker.js
index c6e0a21..f8f6449 100644
--- a/custom/shared/hooks/useActiveSpeaker.js
+++ b/custom/shared/hooks/useActiveSpeaker.js
@@ -6,12 +6,12 @@ import { useParticipants } from '../contexts/ParticipantsProvider';
* (= the current one and only actively speaking person)
*/
export const useActiveSpeaker = () => {
- const { showLocalVideo } = useCallState();
+ const { broadcastRole, 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 (broadcastRole !== 'attendee' && participantCount <= 2) return null;
if (!activeParticipant?.isMicMuted) {
return activeParticipant?.id;
diff --git a/custom/shared/hooks/useAudioLevel.js b/custom/shared/hooks/useAudioLevel.js
index dc8a193..184f17a 100644
--- a/custom/shared/hooks/useAudioLevel.js
+++ b/custom/shared/hooks/useAudioLevel.js
@@ -1,36 +1,54 @@
import { useEffect, useState } from 'react';
+import getConfig from 'next/config';
-export const useAudioLevel = (sessionId) => {
- const [audioLevel, setAudioLevel] = useState(0);
+export const useAudioLevel = (stream) => {
+ const [micVolume, setMicVolume] = useState(0);
+ const { assetPrefix } = getConfig().publicRuntimeConfig;
useEffect(() => {
- if (!sessionId) {
- return false;
+ if (!stream) {
+ setMicVolume(0);
+ return;
}
+ const AudioCtx =
+ typeof AudioContext !== 'undefined'
+ ? AudioContext
+ : typeof webkitAudioContext !== 'undefined'
+ ? webkitAudioContext
+ : null;
+ if (!AudioCtx) return;
+ const audioContext = new AudioCtx();
+ const mediaStreamSource = audioContext.createMediaStreamSource(stream);
+ let node;
- const i = setInterval(async () => {
+ const startProcessing = async () => {
try {
- if (!(window.rtcpeers && window.rtcpeers.sfu)) {
- return;
- }
- const consumer =
- window.rtcpeers.sfu.consumers[`${sessionId}/cam-audio`];
- if (!(consumer && consumer.getStats)) {
- return;
- }
- const level = Array.from((await consumer.getStats()).values()).find(
- (s) => 'audioLevel' in s
- ).audioLevel;
- setAudioLevel(level);
- } catch (e) {
- console.error(e);
- }
- }, 2000);
+ await audioContext.audioWorklet.addModule(
+ `${assetPrefix}/audiolevel-processor.js`
+ );
- return () => clearInterval(i);
- }, [sessionId]);
+ node = new AudioWorkletNode(audioContext, 'audiolevel');
- return audioLevel;
-};
+ node.port.onmessage = (event) => {
+ let volume = 0;
+ if (event.data.volume) volume = event.data.volume;
+ if (!node) return;
+ setMicVolume(volume);
+ };
-export default useAudioLevel;
+ mediaStreamSource.connect(node).connect(audioContext.destination);
+ } catch {}
+ };
+
+ startProcessing();
+
+ return () => {
+ node?.disconnect();
+ node = null;
+ mediaStreamSource?.disconnect();
+ audioContext?.close();
+ };
+ }, [assetPrefix, stream]);
+
+ return micVolume;
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useAudioTrack.js b/custom/shared/hooks/useAudioTrack.js
index 58d37ea..df64fde 100644
--- a/custom/shared/hooks/useAudioTrack.js
+++ b/custom/shared/hooks/useAudioTrack.js
@@ -2,14 +2,13 @@ import { useDeepCompareMemo } from 'use-deep-compare';
import { useTracks } from '../contexts/TracksProvider';
-export const useAudioTrack = (participant) => {
+export const useAudioTrack = (id) => {
const { audioTracks } = useTracks();
return useDeepCompareMemo(() => {
- const audioTrack = audioTracks?.[participant?.id];
- // @ts-ignore
+ const audioTrack = audioTracks?.[id];
return audioTrack?.persistentTrack;
- }, [participant?.id, audioTracks]);
+ }, [id, audioTracks]);
};
-export default useAudioTrack;
+export default useAudioTrack;
\ No newline at end of file
diff --git a/custom/shared/hooks/useCamSubscriptions.js b/custom/shared/hooks/useCamSubscriptions.js
index 0cee45a..c5e3384 100644
--- a/custom/shared/hooks/useCamSubscriptions.js
+++ b/custom/shared/hooks/useCamSubscriptions.js
@@ -15,12 +15,10 @@ export const useCamSubscriptions = (
const { updateCamSubscriptions } = useTracks();
useDeepCompareEffect(() => {
- if (!subscribedIds || !stagedIds) return false;
+ if (!subscribedIds || !stagedIds) return;
const timeout = setTimeout(() => {
updateCamSubscriptions(subscribedIds, stagedIds);
}, throttle);
return () => clearTimeout(timeout);
}, [subscribedIds, stagedIds, throttle, updateCamSubscriptions]);
-};
-
-export default useCamSubscriptions;
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useJoinSound.js b/custom/shared/hooks/useJoinSound.js
index bbf8566..97a45ca 100644
--- a/custom/shared/hooks/useJoinSound.js
+++ b/custom/shared/hooks/useJoinSound.js
@@ -1,39 +1,42 @@
-import { useEffect, useMemo } from 'react';
+import { useEffect, useState } from 'react';
-import { debounce } from 'debounce';
import { useCallState } from '../contexts/CallProvider';
-import { useSound } from './useSound';
+import { useSoundLoader } from './useSoundLoader';
/**
- * Convenience hook to play `join.mp3` when participants join the call
+ * Convenience hook to play `join.mp3` when first other participants joins.
*/
export const useJoinSound = () => {
- const { callObject } = useCallState();
- const { load, play } = useSound('assets/join.mp3');
+ const { callObject: daily } = useCallState();
+ const { joinSound } = useSoundLoader();
+ const [playJoinSound, setPlayJoinSound] = useState(false);
useEffect(() => {
- load();
- }, [load]);
-
- const debouncedPlay = useMemo(() => debounce(() => play(), 200), [play]);
-
- useEffect(() => {
- if (!callObject) return false;
-
- const handleParticipantJoined = () => {
- debouncedPlay();
- };
-
- callObject.on('participant-joined', handleParticipantJoined);
-
+ if (!daily) return;
+ /**
+ * We don't want to immediately play a joined sound, when the user joins the meeting:
+ * Upon joining all other participants, that were already in-call, will emit a
+ * participant-joined event.
+ * In waiting 2 seconds we make sure, that the sound is only played when the user
+ * is **really** the first participant.
+ */
setTimeout(() => {
- handleParticipantJoined();
+ setPlayJoinSound(true);
}, 2000);
+ }, [daily]);
- return () => {
- callObject.off('participant-joined', handleParticipantJoined);
+ useEffect(() => {
+ if (!daily) return;
+ const handleParticipantJoined = () => {
+ // first other participant joined --> play sound
+ if (!playJoinSound || Object.keys(daily.participants()).length !== 2)
+ return;
+ joinSound.play();
};
- }, [callObject, debouncedPlay]);
-};
-export default useJoinSound;
+ daily.on('participant-joined', handleParticipantJoined);
+ return () => {
+ daily.off('participant-joined', handleParticipantJoined);
+ };
+ }, [daily, joinSound, playJoinSound]);
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useNetworkState.js b/custom/shared/hooks/useNetworkState.js
index d0397bd..e8883d3 100644
--- a/custom/shared/hooks/useNetworkState.js
+++ b/custom/shared/hooks/useNetworkState.js
@@ -1,51 +1,55 @@
-/* global rtcpeers */
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallState } from '../contexts/CallProvider';
-import {
- VIDEO_QUALITY_HIGH,
- VIDEO_QUALITY_LOW,
- VIDEO_QUALITY_BANDWIDTH_SAVER,
-} from '../constants';
-
-export const NETWORK_STATE_GOOD = 'good';
-export const NETWORK_STATE_LOW = 'low';
-export const NETWORK_STATE_VERY_LOW = 'very-low';
const STANDARD_HIGH_BITRATE_CAP = 980;
const STANDARD_LOW_BITRATE_CAP = 300;
export const useNetworkState = (
- callObject = null,
- quality = VIDEO_QUALITY_HIGH
+ co = null,
+ quality = 'high'
) => {
- const [threshold, setThreshold] = useState(NETWORK_STATE_GOOD);
+ const [threshold, setThreshold] = useState('good');
+ const lastSetKBS = useRef(null);
+
+ const callState = useCallState();
+
+ const callObject = co ?? callState?.callObject;
const setQuality = useCallback(
- (q) => {
- if (!callObject || typeof rtcpeers === 'undefined') return;
-
+ async (q) => {
+ if (!callObject) return;
const peers = Object.keys(callObject.participants()).length - 1;
- const isSFU = rtcpeers?.currentlyPreferred?.typeName?.() === 'sfu';
-
+ const isSFU = (await callObject.getNetworkTopology()).topology === 'sfu';
const lowKbs = isSFU
? STANDARD_LOW_BITRATE_CAP
- : STANDARD_LOW_BITRATE_CAP / Math.max(1, peers);
+ : Math.floor(STANDARD_LOW_BITRATE_CAP / Math.max(1, peers));
+ const highKbs = isSFU
+ ? STANDARD_HIGH_BITRATE_CAP
+ : Math.floor(STANDARD_HIGH_BITRATE_CAP / Math.max(1, peers));
switch (q) {
- case VIDEO_QUALITY_HIGH:
- callObject.setBandwidth({ kbs: STANDARD_HIGH_BITRATE_CAP });
+ case 'auto':
+ case 'high':
+ if (lastSetKBS.current === highKbs) break;
+ callObject.setBandwidth({
+ kbs: highKbs,
+ });
+ lastSetKBS.current = highKbs;
break;
- case VIDEO_QUALITY_LOW:
+ case 'low':
+ if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({
kbs: lowKbs,
});
+ lastSetKBS.current = lowKbs;
break;
- case VIDEO_QUALITY_BANDWIDTH_SAVER:
+ case 'bandwidth-saver':
callObject.setLocalVideo(false);
+ if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({
kbs: lowKbs,
});
- break;
- default:
+ lastSetKBS.current = lowKbs;
break;
}
},
@@ -57,43 +61,50 @@ export const useNetworkState = (
if (ev.threshold === threshold) return;
switch (ev.threshold) {
- case NETWORK_STATE_VERY_LOW:
- setQuality(VIDEO_QUALITY_BANDWIDTH_SAVER);
- setThreshold(NETWORK_STATE_VERY_LOW);
+ case 'very-low':
+ setQuality('bandwidth-saver');
+ setThreshold('very-low');
break;
- case NETWORK_STATE_LOW:
+ case 'low':
+ setQuality(quality === 'bandwidth-saver' ? quality : 'low');
+ setThreshold('low');
+ break;
+ case 'good':
setQuality(
- quality === VIDEO_QUALITY_BANDWIDTH_SAVER
- ? quality
- : NETWORK_STATE_LOW
+ ['bandwidth-saver', 'low'].includes(quality) ? quality : 'high'
);
- setThreshold(NETWORK_STATE_LOW);
- break;
- case NETWORK_STATE_GOOD:
- setQuality(
- [VIDEO_QUALITY_BANDWIDTH_SAVER, VIDEO_QUALITY_LOW].includes(quality)
- ? quality
- : VIDEO_QUALITY_HIGH
- );
- setThreshold(NETWORK_STATE_GOOD);
- break;
- default:
+ setThreshold('good');
break;
}
},
- [setQuality, threshold, quality]
+ [quality, setQuality, threshold]
);
useEffect(() => {
- if (!callObject) return false;
+ if (!callObject) return;
callObject.on('network-quality-change', handleNetworkQualityChange);
- return () =>
+ return () => {
callObject.off('network-quality-change', handleNetworkQualityChange);
+ };
}, [callObject, handleNetworkQualityChange]);
useEffect(() => {
+ if (!callObject) return;
setQuality(quality);
- }, [quality, setQuality]);
+ let timeout;
+ const handleParticipantCountChange = () => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ setQuality(quality);
+ }, 500);
+ };
+ callObject.on('participant-joined', handleParticipantCountChange);
+ callObject.on('participant-left', handleParticipantCountChange);
+ return () => {
+ callObject.off('participant-joined', handleParticipantCountChange);
+ callObject.off('participant-left', handleParticipantCountChange);
+ };
+ }, [callObject, quality, setQuality]);
return threshold;
-};
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useSound.js b/custom/shared/hooks/useSound.js
index d8d2195..fa988bb 100644
--- a/custom/shared/hooks/useSound.js
+++ b/custom/shared/hooks/useSound.js
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef } from 'react';
-export const useSound = (src) => {
+const defaultNotMuted = () => false;
+
+export const useSound = (src, isMuted = defaultNotMuted) => {
const audio = useRef(null);
useEffect(() => {
@@ -22,17 +24,15 @@ export const useSound = (src) => {
audio.current.load();
}, [audio]);
- const play = useCallback(() => {
- if (!audio.current) return;
+ const play = useCallback(async () => {
+ if (!audio.current || isMuted()) return;
try {
audio.current.currentTime = 0;
- audio.current.play();
+ await audio.current.play();
} catch (e) {
console.error(e);
}
- }, [audio]);
+ }, [audio, isMuted]);
return { load, play };
-};
-
-export default useSound;
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useSoundLoader.js b/custom/shared/hooks/useSoundLoader.js
new file mode 100644
index 0000000..eb3eb07
--- /dev/null
+++ b/custom/shared/hooks/useSoundLoader.js
@@ -0,0 +1,24 @@
+import { useCallback, useMemo } from 'react';
+
+import { useCallState } from '../contexts/CallProvider';
+import { useSound } from './useSound';
+
+export const useSoundLoader = () => {
+ const { enableJoinSound } = useCallState();
+
+ const isJoinSoundMuted = useCallback(
+ () => !enableJoinSound,
+ [enableJoinSound]
+ );
+
+ const joinSound = useSound(`assets/join.mp3`, isJoinSoundMuted);
+
+ const load = useCallback(() => {
+ joinSound.load();
+ }, [joinSound]);
+
+ return useMemo(
+ () => ({ joinSound, load }),
+ [joinSound, load]
+ );
+};
\ No newline at end of file
diff --git a/custom/shared/hooks/useVideoTrack.js b/custom/shared/hooks/useVideoTrack.js
index 0b2129f..5b2c5ff 100644
--- a/custom/shared/hooks/useVideoTrack.js
+++ b/custom/shared/hooks/useVideoTrack.js
@@ -1,22 +1,28 @@
import { useDeepCompareMemo } from 'use-deep-compare';
-import { useTracks } from '../contexts/TracksProvider';
-import { DEVICE_STATE_BLOCKED, DEVICE_STATE_OFF } from '../contexts/useDevices';
-export const useVideoTrack = (participant) => {
+import { useTracks } from '../contexts/TracksProvider';
+import { isLocalId, isScreenId } from '../contexts/participantsState';
+
+export const useVideoTrack = (id) => {
const { videoTracks } = useTracks();
+ const videoTrack = useDeepCompareMemo(
+ () => videoTracks?.[id],
+ [id, videoTracks]
+ );
+
+ /**
+ * MediaStreamTrack's are difficult to compare.
+ * Changes to a video track's id will likely need to be reflected in the UI / DOM.
+ * This usually happens on P2P / SFU switches.
+ */
return useDeepCompareMemo(() => {
- const videoTrack = videoTracks?.[participant?.id];
if (
- videoTrack?.state === DEVICE_STATE_OFF ||
- videoTrack?.state === DEVICE_STATE_BLOCKED ||
- (!videoTrack?.subscribed &&
- participant?.id !== 'local' &&
- !participant.isScreenshare)
+ videoTrack?.state === 'off' ||
+ videoTrack?.state === 'blocked' ||
+ (!videoTrack?.subscribed && !isLocalId(id) && !isScreenId(id))
)
return null;
return videoTrack?.persistentTrack;
- }, [participant?.id, videoTracks]);
-};
-
-export default useVideoTrack;
+ }, [id, videoTrack]);
+};
\ No newline at end of file
diff --git a/custom/shared/icons/emoji-sm.svg b/custom/shared/icons/emoji-sm.svg
new file mode 100644
index 0000000..8d20630
--- /dev/null
+++ b/custom/shared/icons/emoji-sm.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/custom/shared/icons/grid-md.svg b/custom/shared/icons/grid-md.svg
new file mode 100644
index 0000000..10fbfef
--- /dev/null
+++ b/custom/shared/icons/grid-md.svg
@@ -0,0 +1 @@
+grid_on
\ No newline at end of file
diff --git a/custom/shared/icons/lock-md.svg b/custom/shared/icons/lock-md.svg
new file mode 100644
index 0000000..0588a7c
--- /dev/null
+++ b/custom/shared/icons/lock-md.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/custom/shared/icons/play-sm.svg b/custom/shared/icons/play-sm.svg
new file mode 100644
index 0000000..58d6dbb
--- /dev/null
+++ b/custom/shared/icons/play-sm.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/custom/shared/icons/share-sm.svg b/custom/shared/icons/share-sm.svg
new file mode 100644
index 0000000..16effb7
--- /dev/null
+++ b/custom/shared/icons/share-sm.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/custom/shared/icons/speaker-view-md.svg b/custom/shared/icons/speaker-view-md.svg
new file mode 100644
index 0000000..5d1f6f5
--- /dev/null
+++ b/custom/shared/icons/speaker-view-md.svg
@@ -0,0 +1 @@
+view_sidebar
\ No newline at end of file
diff --git a/custom/shared/lib/slugify.js b/custom/shared/lib/slugify.js
new file mode 100644
index 0000000..c9997d2
--- /dev/null
+++ b/custom/shared/lib/slugify.js
@@ -0,0 +1,15 @@
+const convert = (keyword) => {
+ return keyword
+ .toString()
+ .trim()
+ .replace(/\s+/g, '-')
+};
+
+const revert = (keyword) => {
+ return keyword
+ .toString()
+ .trim()
+ .replace('-', ' ')
+}
+
+export const slugify = { convert, revert };
\ No newline at end of file
diff --git a/custom/shared/lib/sortByKey.js b/custom/shared/lib/sortByKey.js
index e13de49..6525050 100644
--- a/custom/shared/lib/sortByKey.js
+++ b/custom/shared/lib/sortByKey.js
@@ -1,15 +1,14 @@
-export const sortByKey = (a, b, key, caseSensitive = true) => {
+export const sortByKey = (key, caseSensitive = true) =>
+(a, b) => {
const aKey =
!caseSensitive && typeof a[key] === 'string'
- ? a[key]?.toLowerCase()
+ ? String(a[key])?.toLowerCase()
: a[key];
const bKey =
!caseSensitive && typeof b[key] === 'string'
- ? b[key]?.toLowerCase()
+ ? String(b[key])?.toLowerCase()
: b[key];
if (aKey > bKey) return 1;
if (aKey < bKey) return -1;
return 0;
-};
-
-export default sortByKey;
+};
\ No newline at end of file
diff --git a/custom/text-chat/components/ChatAside.js b/custom/text-chat/components/ChatAside.js
index 1a4d4b0..7bab3ea 100644
--- a/custom/text-chat/components/ChatAside.js
+++ b/custom/text-chat/components/ChatAside.js
@@ -4,8 +4,8 @@ import Button from '@custom/shared/components/Button';
import { TextInput } from '@custom/shared/components/Input';
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
+import { useMessageSound } from '@custom/text-chat/hooks/useMessageSound';
import { useChat } from '../contexts/ChatProvider';
-import { useMessageSound } from '../hooks/useMessageSound';
export const CHAT_ASIDE = 'chat';