Merge pull request #61 from daily-demos/fitness-demo

Add fitness demo
This commit is contained in:
Jess Mitchell 2022-03-07 09:23:27 -05:00 committed by GitHub
commit 7f3fe2e161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 3907 additions and 847 deletions

View File

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

View File

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

35
custom/fitness-demo/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,55 @@
# Fitness Demo
![Fitness Demo](./image.png)
### 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
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,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: <Room />,
...customComponentForState,
});
// Memoize children to avoid unnecassary renders from HOC
return useMemo(
() => (
<>
<ChatProvider>
<RecordingProvider>
<LiveStreamingProvider>
<ClassStateProvider>
{roomExp && <ExpiryTimer expiry={roomExp} />}
<div className="app">
{componentForState()}
<Modals />
<Asides />
<style jsx>{`
color: white;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loader {
margin: 0 auto;
}
`}</style>
</div>
</ClassStateProvider>
</LiveStreamingProvider>
</RecordingProvider>
</ChatProvider>
</>
),
[componentForState, roomExp]
);
};
App.propTypes = {
customComponentForState: PropTypes.any,
};
export default App;

View File

@ -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 (
<>
<div className="aside-header">
<div
className={`tab ${showAside === PEOPLE_ASIDE && 'active'}`}
onClick={() => setShowAside(PEOPLE_ASIDE)}
>
<p>People</p>
</div>
<div
className={`tab ${showAside === CHAT_ASIDE && 'active'}`}
onClick={() => setShowAside(CHAT_ASIDE)}
>
<p>Chat</p>
</div>
</div>
<style jsx>{`
.aside-header {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 5vh;
text-align: center;
background: var(--gray-wash);
color: var(--gray-dark);
}
.tab {
height: 100%;
width: 50%;
cursor: pointer;
}
.tab.active {
background: var(--reverse)!important;
color: var(--text-default)!important;
font-weight: 900;
}
`}</style>
</>
)
};
export default AsideHeader;

View File

@ -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 (
<>
<PeopleAside />
<NetworkAside />
{asides.map((AsideComponent) => (
<AsideComponent key={AsideComponent.name} />
))}
</>
);
};
export default Asides;

View File

@ -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 (
<>
<DeviceSelectModal />
{modals.map((ModalComponent) => (
<ModalComponent key={ModalComponent.name} />
))}
</>
);
};
export default Modals;

View File

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

View File

@ -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 (
<Aside onClose={() => setShowAside(false)}>
<AsideHeader />
<div className="messages-container" ref={chatWindowRef}>
{chatHistory?.map((chatItem) => (
<div
className={chatItem.isLocal ? 'message local' : 'message'}
key={chatItem.id}
>
<span className="content">{chatItem.message}</span>
<span className="sender">{chatItem.sender}</span>
</div>
))}
</div>
{showEmojis && (
<div className="emojis">
{emojis.map(emoji => (
<Button
key={emoji}
variant="gray"
size="small-circle"
onClick={() => sendMessage(emoji)}
>
{emoji}
</Button>
))}
</div>
)}
<footer className="chat-footer">
<Button
variant="gray"
size="small-circle"
onClick={() => setShowEmojis(!showEmojis)}
>
<IconEmoji />
</Button>
<TextInput
value={newMessage}
placeholder="Type message here"
variant="transparent"
onChange={(e) => setNewMessage(e.target.value)}
/>
<Button
className="send-button"
variant="transparent"
disabled={!newMessage}
onClick={() => {
sendMessage(newMessage);
setNewMessage('');
}}
>
Send
</Button>
</footer>
<style jsx>{`
.emojis {
position: absolute;
display: flex;
bottom: var(--spacing-xxl);
left: 0px;
transform: translateX(calc(-50% + 26px));
z-index: 99;
background: white;
padding: var(--spacing-xxxs);
column-gap: 5px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-depth-2);
}
.messages-container {
flex: 1;
overflow-y: scroll;
}
.message {
margin: var(--spacing-xxs);
padding: var(--spacing-xxs);
background: var(--gray-wash);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.message.local {
background: var(--gray-light);
}
.message.local .sender {
color: var(--primary-dark);
}
.content {
color: var(--text-mid);
display: block;
}
.sender {
font-weight: var(--weight-medium);
font-size: 0.75rem;
}
.chat-footer {
flex-flow: row nowrap;
box-sizing: border-box;
padding: var(--spacing-xxs) 0;
display: flex;
position: relative;
border-top: 1px solid var(--gray-light);
}
.chat-footer :global(.input-container) {
flex: 1;
}
.chat-footer :global(.input-container input) {
padding-right: 0;
}
.chat-footer :global(.send-button) {
padding: 0 var(--spacing-xs);
}
`}</style>
</Aside>
);
};
export default ChatAside;

View File

@ -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 && <WaitingRoom />}
{/* Tray buttons */}
<BasicTray />
{/* Audio tags */}
<Audio />
</>
),
[isOwner]
);
return (
<div className="room">
{children}
{roomComponents}
<style jsx>{`
.room {
flex-flow: column nowrap;
width: 100%;
height: 100%;
display: flex;
}
`}</style>
</div>
);
};
Container.propTypes = {
children: PropTypes.node,
};
export default Container;

View File

@ -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 (
<Button
IconBefore={IconPlay}
size="tiny"
variant="success"
onClick={setClassType}
>
Start Class
</Button>
)
if (classType === CLASS_IN_SESSION)
return (
<Button
size="tiny"
variant="error-light"
onClick={setClassType}
>
End Class
</Button>
)
}, [classType, localParticipant.isOwner, setClassType]);
return useMemo(
() => (
<header className="room-header">
<img
src="/assets/daily-logo.svg"
alt="Daily"
className="logo"
width="80"
height="32"
/>
<HeaderCapsule>
{roomInfo.privacy === 'private' && <IconLock />}
{slugify.revert(roomInfo.name)}
</HeaderCapsule>
<HeaderCapsule>
{`${participantCount} ${
participantCount === 1 ? 'participant' : 'participants'
}`}
</HeaderCapsule>
<HeaderCapsule>
{classType}
{capsuleLabel()}
</HeaderCapsule>
{customCapsule && (
<HeaderCapsule variant={customCapsule.variant}>
{customCapsule.variant === 'recording' && <span />}
{customCapsule.label}
</HeaderCapsule>
)}
<style jsx>{`
.room-header {
display: flex;
flex: 0 0 auto;
column-gap: var(--spacing-xxs);
box-sizing: border-box;
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xxs)
var(--spacing-sm);
align-items: center;
width: 100%;
}
.logo {
margin-right: var(--spacing-xs);
}
`}</style>
</header>
),
[roomInfo.privacy, roomInfo.name, participantCount, customCapsule, classType, capsuleLabel]
);
};
export default Header;

View File

@ -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 (
<Container>
<Header />
<VideoContainer>
<div className="invite-wrapper">
<div className="invite-others">
<Card variant="dark">
<CardHeader>Waiting for others to join?</CardHeader>
<CardBody>
<h3>Copy the link and invite them to the call!</h3>
<div className="link">
<TextInput
variant="border"
value={window.location.href}
disabled
/>
<Button
onClick={() => navigator.clipboard.writeText(window.location.href)}
>
Copy link
</Button>
</div>
</CardBody>
</Card>
</div>
<div className="preview">
<Tile participant={localParticipant} mirrored aspectRatio={DEFAULT_ASPECT_RATIO} />
</div>
<style jsx>{`
.invite-wrapper {
display: flex;
height: 100%;
width: 100%;
}
.invite-others {
margin: auto;
text-align: center;
}
.link {
display: flex;
justify-content: center;
gap: 10px;
}
.preview {
position: absolute;
bottom: 0;
width: 186px;
}
:global(.invite-others .card) {
border: 0!important;
width: 40vw;
}
:global(.invite-others .card input) {
width: 15vw;
}
`}</style>
</div>
</VideoContainer>
</Container>
)
}
export default InviteOthers;

View File

@ -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 }) => (
<div className="person-row">
<div className="name">
{participant.name} {participant.isLocal && '(You)'}
</div>
<div className="actions">
{!isOwner ? (
<>
<span
className={participant.isCamMuted ? 'state error' : 'state success'}
>
{participant.isCamMuted ? <IconCamOff /> : <IconCamOn />}
</span>
<span
className={participant.isMicMuted ? 'state error' : 'state success'}
>
{participant.isMicMuted ? <IconMicOff /> : <IconMicOn />}
</span>
</>
) : (
<>
<Button
size="tiny-square"
disabled={participant.isCamMuted}
variant={participant.isCamMuted ? 'error-light' : 'success-light'}
>
{participant.isCamMuted ? <IconCamOff /> : <IconCamOn />}
</Button>
<Button
size="tiny-square"
disabled={participant.isMicMuted}
variant={participant.isMicMuted ? 'error-light' : 'success-light'}
>
{participant.isMicMuted ? <IconMicOff /> : <IconMicOn />}
</Button>
</>
)}
</div>
<style jsx>{`
.person-row {
display: flex;
border-bottom: 1px solid var(--gray-light);
padding-bottom: var(--spacing-xxxs);
margin-bottom: var(--spacing-xxxs);
justify-content: space-between;
align-items: center;
flex: 1;
}
.person-row .name {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.person-row .actions {
display: flex;
gap: var(--spacing-xxxs);
margin-left: var(--spacing-xs);
}
.mute-state {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
}
.state.error {
color: var(--red-default);
}
.state.success {
color: var(--green-default);
}
`}</style>
</div>
);
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 (
<Aside onClose={() => setShowAside(false)}>
<AsideHeader />
<div className="people-aside">
{isOwner && (
<div className="owner-actions">
<Button
fullWidth
size="tiny"
variant="outline-gray"
onClick={handleMuteAllAudio}
>
Mute all mics
</Button>
<Button
fullWidth
size="tiny"
variant="outline-gray"
onClick={handleMuteAllVideo}
>
Mute all cams
</Button>
</div>
)}
<div className="rows">
{participants.map((p) => (
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
))}
</div>
<style jsx>
{`
.people-aside {
display: block;
}
.owner-actions {
display: flex;
align-items: center;
gap: var(--spacing-xxxs);
margin: var(--spacing-xs) var(--spacing-xxs);
flex: 1;
}
.rows {
margin: var(--spacing-xxs);
flex: 1;
}
`}
</style>
</div>
</Aside>
);
};
export default PeopleAside;

View File

@ -0,0 +1,6 @@
import React from 'react';
import { VideoView } from './VideoView';
export const Room = () => <VideoView />;
export default Room;

View File

@ -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 <InviteOthers />;
return viewMode === VIEW_MODE_SPEAKER ? <SpeakerView />: <GridView />;
};
export default VideoView;

View File

@ -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 (
<>
<WaitingRoomNotification />
{showModal && <WaitingRoomModal onClose={() => setShowModal(false)} />}
</>
);
};
export default WaitingRoom;

View File

@ -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) => (
<Tile
participant={p}
mirrored
key={p.id}
style={{
maxHeight: tileHeight,
maxWidth: tileWidth,
}}
/>
)),
[
activeParticipant,
participantCount,
tileWidth,
tileHeight,
visibleParticipants,
]
);
const handlePrevClick = () => setPage((p) => p - 1);
const handleNextClick = () => setPage((p) => p + 1);
return (
<Container>
<Header />
<VideoContainer>
<div ref={gridRef} className="grid">
{(pages > 1 && page > 1) && (
<Button
className="page-button prev"
type="button"
onClick={handlePrevClick}
>
<IconArrow />
</Button>
)}
<div className="tiles">{tiles}</div>
{(pages > 1 && page < pages) && (
<Button
className="page-button next"
type="button"
onClick={handleNextClick}
>
<IconArrow />
</Button>
)}
<style jsx>{`
.grid {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
width: 100%;
}
.grid .tiles {
align-items: center;
display: flex;
flex-flow: row wrap;
gap: 1px;
max-height: 100%;
justify-content: center;
margin: auto;
overflow: hidden;
width: 100%;
}
.grid :global(.page-button) {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
height: 84px;
padding: 0px var(--spacing-xxxs) 0px var(--spacing-xxs);
background-color: var(--blue-default);
color: white;
border-right: 0px;
}
.grid :global(.page-button):disabled {
color: var(--blue-dark);
background-color: var(--blue-light);
border-color: var(--blue-light);
}
.grid :global(.page-button.prev) {
transform: scaleX(-1);
}
`}</style>
</div>
</VideoContainer>
</Container>
);
};
GridView.propTypes = {
maxTilesPerPage: PropTypes.number,
};
export default GridView;

View File

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

View File

@ -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 (
<div className="intro">
<Card>
<div className="jc-card">
<CardHeader>Join a class</CardHeader>
<CardBody>
{Object.keys(rooms).length === 0 && (
<p>
Looks like there&apos;s no class going on right now,
start with creating one!
</p>
)}
{Object.keys(rooms).map(room => (
<div className="room" key={room}>
<div>
<div className="label">{slugify.revert(room)}</div>
<span>
{`${rooms[room].length} ${rooms[room].length > 1 ? 'people' : 'person'} in class`}
</span>
</div>
<div className="join-room">
<Button
variant="gray"
size="tiny"
onClick={() => onJoin(slugify.convert(room), 'join')}>
Join Class
</Button>
</div>
</div>
))}
</CardBody>
</div>
</Card>
<span className="or-text">OR</span>
<Card>
<form onSubmit={(e) => {
e.preventDefault();
onJoin(slugify.convert(roomName), 'create', duration, privacy)
}}>
<div className="jc-card">
<CardHeader>Create a class</CardHeader>
<CardBody>
{error && (
<Well variant="error">
Failed to create class <p>{error}</p>
</Well>
)}
{tokenError && (
<Well variant="error">
Failed to obtain token <p>{tokenError}</p>
</Well>
)}
<Field label="Give your class a name">
<TextInput
type="text"
placeholder="Eg. Super stretchy morning flow"
defaultValue={roomName}
onChange={(e) => setRoomName(e.target.value)}
required
/>
</Field>
<Field label="How long would you like to be?">
<SelectInput
onChange={(e) => setDuration(e.target.value)}
value={duration}>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">60 minutes</option>
</SelectInput>
</Field>
<Field label="Privacy">
<BooleanInput
value={privacy}
onChange={e => setPrivacy(e.target.checked)}
/>
<p>{privacy ? 'Public class (anyone can join the class)': 'Private (attendees will request access to class)'}</p>
</Field>
</CardBody>
<CardFooter>
<Button
loading={fetching}
fullWidth
type="submit"
>
{fetching ? 'Creating...' : 'Create class'}
</Button>
</CardFooter>
</div>
</form>
</Card>
<style jsx>{`
.intro {
display: flex;
gap: 15px;
margin: auto;
}
.or-text {
margin: auto;
}
.room {
display: flex;
width: 25vw;
border-bottom: 1px solid var(--gray-light);
padding: var(--spacing-xxs) 0;
gap: 10px;
}
.room .label {
font-weight: var(--weight-medium);
color: var(--text-default);
}
.room .join-room {
margin-left: auto;
margin-bottom: auto;
margin-top: auto;
}
.jc-card {
width: 25vw;
}
@media screen and (max-width: 650px) {
.intro {
display: flex;
flex-direction: column;
}
.jc-card {
width: 75vw;
}
}
@media (min-width: 650px) and (max-width: 1000px) {
.intro {
display: flex;
flex-direction: column;
}
.jc-card {
width: 50vw;
}
}
`}
</style>
</div>
);
};
Intro.propTypes = {
room: PropTypes.string,
onJoin: PropTypes.func.isRequired,
};
export default Intro;

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Card, CardBody, CardHeader } from '@custom/shared/components/Card';
export const NotConfigured = () => (
<Card>
<CardHeader>Environmental variables not set</CardHeader>
<CardBody>
<p>
Please ensure you have set both the <code>DAILY_API_KEY</code> and{' '}
<code>DAILY_DOMAIN</code> environmental variables. An example can be
found in the provided <code>env.example</code> file.
</p>
<p>
If you do not yet have a Daily developer account, please{' '}
<a
href="https://dashboard.daily.co/signup"
target="_blank"
rel="noreferrer"
>
create one now
</a>
. You can find your Daily API key on the{' '}
<a
href="https://dashboard.daily.co/developers"
target="_blank"
rel="noreferrer"
>
developer page
</a>{' '}
of the dashboard.
</p>
</CardBody>
</Card>
);
export default NotConfigured;

View File

@ -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 (
<Tile
aspectRatio={initialRatio}
hideName={hideName}
participant={item}
mirrored={false}
style={{
height,
maxWidth,
}}
/>
);
}
return (
<Tile
aspectRatio={ratio}
participant={item}
onVideoResize={handleResize}
style={{
maxHeight: height,
maxWidth: height * ratio,
}}
/>
);
};
export default ScreenPinTile;

View File

@ -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 (
<div ref={viewRef}>
{visibleItems.map((item) => (
<div
className="tileWrapper"
key={item.id}
style={{
height,
maxWidth,
}}
>
<ScreenPinTile
height={height}
hideName={!showNames}
item={item}
maxWidth={maxWidth}
ratio={aspectRatio}
/>
</div>
))}
<style jsx>{`
div {
align-items: center;
display: flex;
flex-wrap: wrap;
height: 100%;
justify-content: center;
width: 100%;
}
div :global(.tileWrapper) {
background: var(--background);
align-items: center;
display: flex;
flex: none;
justify-content: center;
position: relative;
width: 100%;
}
div :global(.tile .content) {
margin: auto;
max-height: 100%;
max-width: 100%;
}
`}</style>
</div>
);
};
export default ScreensAndPins;

View File

@ -0,0 +1 @@
export { ScreensAndPins } from './ScreensAndPins';

View File

@ -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 (
<Tile
aspectRatio={finalRatio}
participant={participant}
style={style}
videoFit={videoFit}
showActiveSpeaker={false}
onVideoResize={handleNativeAspectRatio}
/>
);
};
SpeakerTile.propTypes = {
participant: PropTypes.object,
screenRef: PropTypes.object,
};
export default SpeakerTile;

View File

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

View File

@ -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(
() => <ScreensAndPins items={screensAndPinned} />,
[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 (
<div className="speaker-view">
<Container>
<Header />
<VideoContainer>
<div ref={activeRef} className="active">
{screensAndPinned.length > 0 ? (
screenShareTiles
) : (
<SpeakerTile screenRef={activeRef} participant={currentSpeaker} />
)}
</div>
</VideoContainer>
</Container>
{showSidebar && (
<ParticipantBar
fixed={fixedItems}
others={otherItems}
width={SIDEBAR_WIDTH}
/>
)}
<style jsx>{`
.speaker-view {
display: flex;
height: 100%;
width: 100%;
position: relative;
}
.active {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`}</style>
</div>
);
};
export default SpeakerView;

View File

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

View File

@ -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 (
<TrayButton
label="Chat"
bubble={hasNewMessages}
onClick={() => toggleAside(CHAT_ASIDE)}
>
<IconChat />
</TrayButton>
);
};
export default ChatTray;

View File

@ -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 (
<TrayButton
label={isRecording ? 'Stop' : 'Record'}
orange={isRecording}
onClick={() => openModal(RECORDING_MODAL)}
>
<IconRecord />
</TrayButton>
);
};
export default Tray;

View File

@ -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 (
<TrayButton
label={isSharingScreen ? 'Stop': 'Share'}
orange={isSharingScreen}
disabled={disabled}
onClick={toggleScreenShare}
>
<IconShare />
</TrayButton>
);
};
export default ScreenShareTray;

View File

@ -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 (
<TrayButton
label={isStreaming ? 'Live' : 'Stream'}
orange={isStreaming}
onClick={() => openModal(LIVE_STREAMING_MODAL)}
>
<IconStream />
</TrayButton>
);
};
export default Stream;

View File

@ -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 (
<>
<ChatTray />
<ScreenShareTray />
<RecordTray />
<StreamTray />
</>
);
};
export default Tray;

View File

@ -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 (
<ChatContext.Provider
value={{
sendMessage,
chatHistory,
hasNewMessages,
setHasNewMessages,
}}
>
{children}
</ChatContext.Provider>
);
};
ChatProvider.propTypes = {
children: PropTypes.node,
};
export const useChat = () => useContext(ChatContext);

View File

@ -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 (
<ClassStateContext.Provider value={{ classType, setClassType }}>
{children}
</ClassStateContext.Provider>
);
};
ClassStateProvider.propTypes = {
children: PropTypes.node,
};
export const useClassState = () => useContext(ClassStateContext);

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1 @@
// Note: I am here because next-transpile-modules requires a mainfile

View File

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

View File

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

View File

@ -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 <NotConfigured />;
return (
<UIStateProvider
asides={asides}
modals={modals}
customTrayComponent={customTrayComponent}
>
<CallProvider
domain={domain}
room={room}
token={t}
subscribeToTracksAutomatically={subscribeToTracksAutomatically}
cleanURLOnJoin
>
<ParticipantsProvider>
<TracksProvider>
<MediaDeviceProvider>
<WaitingRoomProvider>
{customAppComponent || <App />}
</WaitingRoomProvider>
</MediaDeviceProvider>
</TracksProvider>
</ParticipantsProvider>
</CallProvider>
</UIStateProvider>
)
};
export default Room;
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: defaultProps,
};
}
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking',
}
}

View File

@ -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 (
<>
<Head>
<title>Daily - {process.env.PROJECT_TITLE}</title>
</Head>
<GlobalStyle />
<Component
asides={App.asides}
modals={App.modals}
customTrayComponent={App.customTrayComponent}
customAppComponent={App.customAppComponent}
{...pageProps}
/>
</>
);
}
App.defaultProps = {
Component: null,
pageProps: {},
};
App.propTypes = {
Component: PropTypes.elementType,
pageProps: PropTypes.object,
};
App.asides = [ChatAside];
App.modals = [RecordingModal, LiveStreamingModal];
App.customTrayComponent = <Tray />;
App.customAppComponent = <CustomApp />;
export default App;

View File

@ -0,0 +1,23 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600&display=optional"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<main>
{(() => {
if (!isConfigured) return <NotConfigured />;
return (
<Intro
tokenError={tokenError}
fetching={fetching}
error={error}
domain={domain}
onJoin={(room, type, duration = 60, privacy = 'public') =>
type === 'join' ? router.push(`/${room}`): createRoom(room, duration, privacy)
}
/>
);
})()}
<style jsx>{`
height: 100vh;
display: grid;
align-items: center;
justify-content: center;
background: var(--reverse);
`}</style>
</main>
);
}
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,
};
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import MessageCard from '@custom/shared/components/MessageCard';
export default function RoomNotFound() {
return (
<div className="not-found">
<MessageCard error header="Room not found">
The room you are trying to join does not exist. Have you created the
room using the Daily REST API or the dashboard?
</MessageCard>
<style jsx>{`
display: grid;
align-items: center;
justify-content: center;
grid-template-columns: 620px;
width: 100%;
height: 100vh;
`}</style>
</div>
);
}

View File

@ -0,0 +1,14 @@
<svg width="80" height="32" viewBox="0 0 80 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5886 27.0149C23.8871 27.0149 25.7976 25.6716 26.6035 24.0896V26.6866H30.9021V4H26.6035V13.403C25.7379 11.8209 24.1856 10.7164 21.6782 10.7164C17.7677 10.7164 14.8125 13.7313 14.8125 18.8657V19.1045C14.8125 24.2985 17.7976 27.0149 21.5886 27.0149ZM22.8722 23.6418C20.7229 23.6418 19.2304 22.1194 19.2304 19.0149V18.7761C19.2304 15.6716 20.5737 14.0299 22.9916 14.0299C25.3498 14.0299 26.7229 15.6119 26.7229 18.7164V18.9552C26.7229 22.1194 25.1409 23.6418 22.8722 23.6418Z" fill="#121A24"/>
<path d="M37.9534 27.0149C40.4011 27.0149 41.7743 26.0597 42.6698 24.806V26.6866H46.8787V16.5075C46.8787 12.2687 44.1623 10.7164 40.3414 10.7164C36.5205 10.7164 33.5951 12.3582 33.3265 16.0597H37.416C37.5951 14.7164 38.3713 13.8507 40.0728 13.8507C42.0429 13.8507 42.6101 14.8657 42.6101 16.7164V17.3433H40.8489C36.0728 17.3433 32.7295 18.7164 32.7295 22.3582C32.7295 25.6418 35.1175 27.0149 37.9534 27.0149ZM39.2369 24C37.6548 24 36.9683 23.2537 36.9683 22.1194C36.9683 20.4478 38.431 19.9104 40.9384 19.9104H42.6101V21.2239C42.6101 22.9552 41.1474 24 39.2369 24Z" fill="#121A24"/>
<path d="M49.647 26.6866H53.9456V11.0746H49.647V26.6866ZM51.7665 8.95522C53.1694 8.95522 54.2441 7.9403 54.2441 6.59702C54.2441 5.25373 53.1694 4.23881 51.7665 4.23881C50.3933 4.23881 49.3187 5.25373 49.3187 6.59702C49.3187 7.9403 50.3933 8.95522 51.7665 8.95522Z" fill="#121A24"/>
<path d="M56.9765 26.6866H61.275V4H56.9765V26.6866Z" fill="#121A24"/>
<path d="M70.5634 32L78.9917 11.0746H74.8711L71.3499 20.4478L67.5589 11.0746H62.9021L69.1523 25.1953L66.3779 32H70.5634Z" fill="#121A24"/>
<path d="M0 32H4.1875L17.0766 0H12.9916L0 32Z" fill="url(#paint0_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="8.88727" y1="-0.222885" x2="8.88727" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#1BEBB9"/>
<stop offset="1" stop-color="#FF9254"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,14 @@
<svg width="80" height="32" viewBox="0 0 80 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5886 27.0149C23.8871 27.0149 25.7976 25.6716 26.6035 24.0896V26.6866H30.9021V4H26.6035V13.403C25.7379 11.8209 24.1856 10.7164 21.6782 10.7164C17.7677 10.7164 14.8125 13.7313 14.8125 18.8657V19.1045C14.8125 24.2985 17.7976 27.0149 21.5886 27.0149ZM22.8722 23.6418C20.7229 23.6418 19.2304 22.1194 19.2304 19.0149V18.7761C19.2304 15.6716 20.5737 14.0299 22.9916 14.0299C25.3498 14.0299 26.7229 15.6119 26.7229 18.7164V18.9552C26.7229 22.1194 25.1409 23.6418 22.8722 23.6418Z" fill="white"/>
<path d="M37.9534 27.0149C40.4011 27.0149 41.7743 26.0597 42.6698 24.806V26.6866H46.8787V16.5075C46.8787 12.2687 44.1623 10.7164 40.3414 10.7164C36.5205 10.7164 33.5951 12.3582 33.3265 16.0597H37.416C37.5951 14.7164 38.3713 13.8507 40.0728 13.8507C42.0429 13.8507 42.6101 14.8657 42.6101 16.7164V17.3433H40.8489C36.0728 17.3433 32.7295 18.7164 32.7295 22.3582C32.7295 25.6418 35.1175 27.0149 37.9534 27.0149ZM39.2369 24C37.6548 24 36.9683 23.2537 36.9683 22.1194C36.9683 20.4478 38.431 19.9104 40.9384 19.9104H42.6101V21.2239C42.6101 22.9552 41.1474 24 39.2369 24Z" fill="white"/>
<path d="M49.647 26.6866H53.9456V11.0746H49.647V26.6866ZM51.7665 8.95522C53.1694 8.95522 54.2441 7.9403 54.2441 6.59702C54.2441 5.25373 53.1694 4.23881 51.7665 4.23881C50.3933 4.23881 49.3187 5.25373 49.3187 6.59702C49.3187 7.9403 50.3933 8.95522 51.7665 8.95522Z" fill="white"/>
<path d="M56.9765 26.6866H61.275V4H56.9765V26.6866Z" fill="white"/>
<path d="M70.5634 32L78.9917 11.0746H74.8711L71.3499 20.4478L67.5589 11.0746H62.9021L69.1523 25.1953L66.3779 32H70.5634Z" fill="white"/>
<path d="M0 32H4.1875L17.0766 0H12.9916L0 32Z" fill="url(#paint0_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="8.88727" y1="-0.222885" x2="8.88727" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#1BEBB9"/>
<stop offset="1" stop-color="#FF9254"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,14 @@
<svg width="476" height="124" viewBox="0 0 476 124" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="476" height="124" fill="url(#paint0_linear_605_2751)"/>
<path d="M238.5 68.5C241.814 68.5 244.5 65.8137 244.5 62.5C244.5 59.1863 241.814 56.5 238.5 56.5C235.186 56.5 232.5 59.1863 232.5 62.5C232.5 65.8137 235.186 68.5 238.5 68.5Z" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M259.923 41.499C265.228 46.91 268.5 54.323 268.5 62.5C268.5 70.676 265.229 78.089 259.924 83.5" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M251.502 50.052C254.598 53.285 256.5 57.67 256.5 62.5C256.5 67.331 254.597 71.717 251.5 74.95" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M217.077 41.499C211.772 46.91 208.5 54.323 208.5 62.5C208.5 70.676 211.771 78.089 217.076 83.5" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M225.498 50.052C222.402 53.285 220.5 57.67 220.5 62.5C220.5 67.331 222.403 71.717 225.5 74.95" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_605_2751" x1="478.164" y1="-0.873246" x2="131.851" y2="286.259" gradientUnits="userSpaceOnUse">
<stop stop-color="#F1AFAF"/>
<stop offset="1" stop-color="#0094FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@ -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 = () => {
</div>
)}
<div className="rows">
{allParticipants.map((p) => (
{participants.map((p) => (
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
))}
</div>

View File

@ -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 <CombinedAudioTrack tracks={renderedTracks} />;
}
return Object.entries(renderedTracks).map(([id, track]) => (

View File

@ -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 ? (
<audio autoPlay playsInline ref={audioRef}>
<track kind="captions" />
</audio>
) : null;
return track ? <audio autoPlay playsInline ref={audioRef} /> : null;
};
AudioTrack.propTypes = {

View File

@ -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 (
<audio autoPlay playsInline ref={audioEl}>
<track kind="captions" />
</audio>
);
return <audio autoPlay playsInline ref={audioEl} />;
};
CombinedAudioTrack.propTypes = {

View File

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

View File

@ -6,18 +6,19 @@ export const Capsule = ({ children, variant }) => (
<span className={classNames('capsule', variant)}>
{children}
<style jsx>{`
display: inline-flex;
padding: 4px 6px;
margin: 0 6px;
align-items: center;
line-height: 1;
justify-content: center;
border-radius: 5px;
font-size: 0.75rem;
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 1px;
.capsule {
display: inline-flex;
padding: 4px 6px;
margin: 0 6px;
align-items: center;
line-height: 1;
justify-content: center;
border-radius: 5px;
font-size: 0.75rem;
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 1px;
}
.capsule.success {
background-color: var(--green-default);
color: #ffffff;

View File

@ -2,14 +2,22 @@ import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
export const Card = ({ children, className }) => (
<div className={classNames('card', className)}>
export const Card = ({ children, className, variant }) => (
<div className={classNames('card', className, variant)}>
{children}
<style jsx>{`
background: white;
box-sizing: border-box;
border-radius: var(--radius-md);
padding: var(--spacing-md);
.card {
background: var(--reverse);
box-sizing: border-box;
border-radius: var(--radius-md);
border: 1px solid #C8D1DC;
padding: var(--spacing-md);
}
.card.dark {
background-color: var(--blue-dark);;
}
`}</style>
</div>
);
@ -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 }) => (
<div className="card-body">
{children}
<style jsx>{`
color: var(--text-mid);
.card-body {
color: var(--text-mid);
}
& + :global(.card-footer) {
margin-top: var(--spacing-md);

View File

@ -8,13 +8,15 @@ export const DeviceSelect = () => {
cams,
mics,
speakers,
currentDevices,
setCamDevice,
setMicDevice,
setSpeakersDevice,
currentCam,
setCurrentCam,
currentMic,
setCurrentMic,
currentSpeaker,
setCurrentSpeaker,
} = useMediaDevices();
if (!currentDevices) {
if (!currentCam && !currentMic && !currentSpeaker) {
return <div>Loading devices...</div>;
}
@ -22,9 +24,9 @@ export const DeviceSelect = () => {
<>
<Field label="Select camera:">
<SelectInput
onChange={(e) => setCamDevice(cams[e.target.value])}
onChange={(e) => setCurrentCam(cams[e.target.value])}
value={cams.findIndex(
(i) => i.deviceId === currentDevices.camera.deviceId
(i) => i.deviceId === currentCam.deviceId
)}
>
{cams.map(({ deviceId, label }, i) => (
@ -37,9 +39,9 @@ export const DeviceSelect = () => {
<Field label="Select microphone:">
<SelectInput
onChange={(e) => setMicDevice(mics[e.target.value])}
onChange={(e) => setCurrentMic(mics[e.target.value])}
value={mics.findIndex(
(i) => i.deviceId === currentDevices.mic.deviceId
(i) => i.deviceId === currentMic.deviceId
)}
>
{mics.map(({ deviceId, label }, i) => (
@ -56,9 +58,9 @@ export const DeviceSelect = () => {
{speakers.length > 0 && (
<Field label="Select speakers:">
<SelectInput
onChange={(e) => setSpeakersDevice(speakers[e.target.value])}
onChange={(e) => setCurrentSpeaker(speakers[e.target.value])}
value={speakers.findIndex(
(i) => i.deviceId === currentDevices.speaker.deviceId
(i) => i.deviceId === currentSpeaker.deviceId
)}
>
{speakers.map(({ deviceId, label }, i) => (

View File

@ -105,17 +105,12 @@ export const HairCheck = () => {
]);
const hasError = useMemo(() => {
if (
!deviceState ||
return !(!deviceState ||
[
DEVICE_STATE_LOADING,
DEVICE_STATE_PENDING,
DEVICE_STATE_GRANTED,
].includes(deviceState)
) {
return false;
}
return true;
].includes(deviceState));
}, [deviceState]);
const camErrorVerbose = useMemo(() => {

View File

@ -113,7 +113,7 @@ const InputContainer = ({ children, prefix, className }) => (
opacity: 1;
}
.dark :global(input)::-moz-placeholder {
ccolor: var(--text-mid);
color: var(--text-mid);
opacity: 1;
}
.dark :global(input)::-ms-input-placeholder {
@ -126,6 +126,12 @@ const InputContainer = ({ children, prefix, className }) => (
border: 0px;
box-shadow: none;
}
.border :global(input) {
background: transparent;
border: 1px solid var(--reverse);
color: var(--reverse);
}
`}</style>
</div>
);

View File

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

View File

@ -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 ? (
<Video
ref={videoRef}
fit={videoFit}
isScreen={participant.isScreenshare}
participantId={participant?.id}
videoTrack={videoTrack}
/>
) : (
showAvatar && (

View File

@ -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 <video autoPlay muted playsInline ref={videoEl} {...rest} />;
}),
(p, n) => shallowEqualObjects(p, n)
return (
<>
<video
className={classNames(fit, {
isMirrored,
isMobile,
playable: videoTrack?.enabled,
})}
autoPlay
muted
playsInline
ref={videoEl}
{...props}
/>
<style jsx>{`
video {
opacity: 0;
}
video.playable {
opacity: 1;
}
video.isMirrored {
transform: scale(-1, 1);
}
video.isMobile {
border-radius: 4px;
display: block;
height: 100%;
position: relative;
width: 100%;
}
video:not(.isMobile) {
height: calc(100% + 4px);
left: -2px;
object-position: center;
position: absolute;
top: -2px;
width: calc(100% + 4px);
}
video.contain {
object-fit: contain;
}
video.cover {
object-fit: cover;
}
`}</style>
</>
);
}
);
Video.displayName = 'Video';
Video.propTypes = {
videoTrack: PropTypes.any,
mirrored: PropTypes.bool,
participantId: PropTypes.string,
};
export default Video;
Video.displayName = 'Video';

View File

@ -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 (
<Tray className="tray">
<TrayButton
@ -52,7 +65,7 @@ export const BasicTray = () => {
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
</TrayButton>
{responsive.isMobile() && showMore && (
<div className="more-options">
<div className="more-options" ref={ref}>
<Button
className="translucent"
onClick={() => openModal(DEVICE_MODAL)}

View File

@ -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 (
<TrayButton label="Mic" onClick={onClick} orange={isMuted}>
{isMuted ? <IconMicOff /> : <IconMicOn />}

View File

@ -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 (
<CallContext.Provider
value={{
@ -115,13 +144,19 @@ export const CallProvider = ({
showLocalVideo,
roomExp,
enableRecording,
enableScreenShare,
enableJoinSound,
videoQuality,
setVideoQuality,
roomInfo,
setRoomInfo,
setBandwidth,
setRedirectOnLeave,
setShowLocalVideo,
setEnableScreenShare,
startCloudRecording,
subscribeToTracksAutomatically,
setEnableJoinSound
}}
>
{children}

View File

@ -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 (
<MediaDeviceContext.Provider
value={{
cams,
mics,
speakers,
camError,
micError,
currentDevices,
cams,
currentCam,
currentMic,
currentSpeaker,
deviceState,
isCamMuted: localParticipant.isCamMuted,
isMicMuted: localParticipant.isMicMuted,
setMicDevice,
setCamDevice,
setSpeakersDevice,
micError,
mics,
refreshDevices,
setCurrentCam: selectCamera,
setCurrentMic: selectMic,
setCurrentSpeaker: selectSpeaker,
speakers,
}}
>
{children}

View File

@ -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 (
<ParticipantsContext.Provider
value={{
activeParticipant,
allParticipants,
currentSpeaker,
localParticipant,
muteAll,
muteNewParticipants,
participantCount,
participantMarkedForRemoval,
participants,
screens,
muteNewParticipants,
muteAll,
setParticipantMarkedForRemoval,
setUsername,
swapParticipantPosition,
@ -344,8 +337,4 @@ export const ParticipantsProvider = ({ children }) => {
);
};
ParticipantsProvider.propTypes = {
children: PropTypes.node,
};
export const useParticipants = () => useContext(ParticipantsContext);
export const useParticipants = () => useContext(ParticipantsContext);

View File

@ -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 (
<TracksContext.Provider
value={{
audioTracks: state.audioTracks,
videoTracks: state.videoTracks,
maxCamSubscriptions,
subscribeToCam,
updateCamSubscriptions,
remoteParticipantIds,
recentSpeakerIds,
}}
>
{children}
@ -296,8 +379,4 @@ export const TracksProvider = ({ children }) => {
);
};
TracksProvider.propTypes = {
children: PropTypes.node,
};
export const useTracks = () => useContext(TracksContext);
export const useTracks = () => useContext(TracksContext);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]);
};

View File

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

View File

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

View File

@ -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]
);
};

View File

@ -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]);
};

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 16C8.32843 16 9 15.3284 9 14.5C9 13.6716 8.32843 13 7.5 13C6.67157 13 6 13.6716 6 14.5C6 15.3284 6.67157 16 7.5 16Z" fill="#2B3F56"/>
<path d="M16.5 16C17.3284 16 18 15.3284 18 14.5C18 13.6716 17.3284 13 16.5 13C15.6716 13 15 13.6716 15 14.5C15 15.3284 15.6716 16 16.5 16Z" fill="#2B3F56"/>
<path d="M12 0C5.383 0 0 5.383 0 12C0 18.617 5.383 24 12 24C18.617 24 24 18.617 24 12C24 5.383 18.617 0 12 0ZM12 22C6.486 22 2 17.514 2 12C2.031 11 4.544 9.951 6.855 9.951C6.876 9.951 6.896 9.951 6.917 9.951C9.442 9.949 12.558 9.955 15.796 7.472C18.375 10.946 22 12 22 12C22 17.514 17.514 22 12 22Z" fill="#2B3F56"/>
</svg>

After

Width:  |  Height:  |  Size: 725 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>grid_on</title><g fill="none" class="nc-icon-wrapper"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z" fill="#ffffff"></path></g></svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6H10V4C10 3.46957 9.78929 2.96086 9.41421 2.58579C9.03914 2.21071 8.53043 2 8 2C7.46957 2 6.96086 2.21071 6.58579 2.58579C6.21071 2.96086 6 3.46957 6 4V6H4V4C4 2.93913 4.42143 1.92172 5.17157 1.17157C5.92172 0.421427 6.93913 0 8 0C9.06087 0 10.0783 0.421427 10.8284 1.17157C11.5786 1.92172 12 2.93913 12 4V6Z" fill="white"/>
<path d="M14 7H2C1.73478 7 1.48043 7.10536 1.29289 7.29289C1.10536 7.48043 1 7.73478 1 8V15C1 15.2652 1.10536 15.5196 1.29289 15.7071C1.48043 15.8946 1.73478 16 2 16H14C14.2652 16 14.5196 15.8946 14.7071 15.7071C14.8946 15.5196 15 15.2652 15 15V8C15 7.73478 14.8946 7.48043 14.7071 7.29289C14.5196 7.10536 14.2652 7 14 7ZM8 13C7.60444 13 7.21776 12.8827 6.88886 12.6629C6.55996 12.4432 6.30362 12.1308 6.15224 11.7654C6.00087 11.3999 5.96126 10.9978 6.03843 10.6098C6.1156 10.2219 6.30608 9.86549 6.58579 9.58579C6.86549 9.30608 7.22186 9.1156 7.60982 9.03843C7.99778 8.96126 8.39991 9.00087 8.76537 9.15224C9.13082 9.30362 9.44318 9.55996 9.66294 9.88886C9.8827 10.2178 10 10.6044 10 11C10 11.5304 9.78929 12.0391 9.41421 12.4142C9.03914 12.7893 8.53043 13 8 13Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="9" height="12" viewBox="0 0 9 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.783 0.0880035C0.707928 0.0364627 0.620207 0.00640194 0.529301 0.00106545C0.438396 -0.00427103 0.34776 0.0153194 0.267173 0.0577225C0.186587 0.100126 0.11911 0.163731 0.0720258 0.241675C0.0249413 0.319619 3.68967e-05 0.408942 0 0.500004V11.5C0.000167265 11.5911 0.02522 11.6804 0.0724523 11.7583C0.119685 11.8362 0.187301 11.8997 0.268 11.942C0.339356 11.9802 0.419063 12.0001 0.5 12C0.601059 11.9999 0.699728 11.9693 0.783 11.912L8.783 6.412C8.84983 6.36605 8.90447 6.30454 8.94222 6.23276C8.97998 6.16098 8.99971 6.0811 8.99971 6C8.99971 5.9189 8.97998 5.83902 8.94222 5.76725C8.90447 5.69547 8.84983 5.63395 8.783 5.588L0.783 0.0880035Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 22H6" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 2L8 9" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 2H23V18H1V12" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 2H1V8" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>view_sidebar</title><g fill="none" class="nc-icon-wrapper"><path d="M2 4v16h20V4H2zm18 4.67h-2.5V6H20v2.67zm-2.5 2H20v2.67h-2.5v-2.67zM4 6h11.5v12H4V6zm13.5 12v-2.67H20V18h-2.5z" fill="#ffffff"></path></g></svg>

After

Width:  |  Height:  |  Size: 301 B

View File

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

View File

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

View File

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