|
|
@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
|
||||||
import { Audio } from '@custom/shared/components/Audio';
|
import { Audio } from '@custom/shared/components/Audio';
|
||||||
import { BasicTray } from '@custom/shared/components/Tray';
|
import { BasicTray } from '@custom/shared/components/Tray';
|
||||||
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
|
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 PropTypes from 'prop-types';
|
||||||
import { WaitingRoom } from './WaitingRoom';
|
import { WaitingRoom } from './WaitingRoom';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": ["inline-react-svg"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Fitness Demo
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Live example
|
||||||
|
|
||||||
|
**[See it in action here ➡️](https://custom-fitness-demo.vercel.app)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What does this demo do?
|
||||||
|
|
||||||
|
- Built on [NextJS](https://nextjs.org/)
|
||||||
|
- Create a Daily instance using call object mode
|
||||||
|
- Manage user media devices
|
||||||
|
- Render UI based on the call state
|
||||||
|
- Handle media and call errors
|
||||||
|
- Obtain call access token via Daily REST API
|
||||||
|
- Handle preauthentication, knock for access and auto join
|
||||||
|
|
||||||
|
Please note: this demo is not currently mobile optimised
|
||||||
|
|
||||||
|
### Getting started
|
||||||
|
|
||||||
|
```
|
||||||
|
# set both DAILY_API_KEY and DAILY_DOMAIN
|
||||||
|
mv env.example .env.local
|
||||||
|
|
||||||
|
# from project root...
|
||||||
|
yarn
|
||||||
|
yarn workspace @custom/fitness-demo dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## How does this example work?
|
||||||
|
|
||||||
|
This demo puts to work the following [shared libraries](../shared):
|
||||||
|
|
||||||
|
**[MediaDeviceProvider.js](../shared/contexts/MediaDeviceProvider.js)**
|
||||||
|
Convenience context that provides an interface to media devices throughout app
|
||||||
|
|
||||||
|
**[useDevices.js](../shared/contexts/useDevices.js)**
|
||||||
|
Hook for managing the enumeration and status of client media devices)
|
||||||
|
|
||||||
|
**[CallProvider.js](../shared/contexts/CallProvider.js)**
|
||||||
|
Primary call context that manages Daily call state, participant state and call object interaction
|
||||||
|
|
||||||
|
**[useCallMachine.js](../shared/contexts/useCallMachine.js)**
|
||||||
|
Abstraction hook that manages Daily call state and error handling
|
||||||
|
|
||||||
|
**[ParticipantsProvider.js](../shared/contexts/ParticipantsProvider.js)**
|
||||||
|
Manages participant state and abstracts common selectors / derived data
|
||||||
|
|
||||||
|
## Deploy your own on Vercel
|
||||||
|
|
||||||
|
[](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { App as default } from './App';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { VideoView } from './VideoView';
|
||||||
|
|
||||||
|
export const Room = () => <VideoView />;
|
||||||
|
|
||||||
|
export default Room;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { GridView as default } from './GridView';
|
||||||
|
export { GridView } from './GridView';
|
||||||
|
|
@ -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'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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { ScreensAndPins } from './ScreensAndPins';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { SpeakerTile as default } from './SpeakerTile';
|
||||||
|
export { SpeakerTile } from './SpeakerTile';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { SpeakerView as default } from './SpeakerView';
|
||||||
|
export { SpeakerView } from './SpeakerView';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
|
@ -0,0 +1 @@
|
||||||
|
// Note: I am here because next-transpile-modules requires a mainfile
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -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 |
|
|
@ -22,9 +22,7 @@ export const NetworkAside = () => {
|
||||||
}, [callObject]);
|
}, [callObject]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callObject) {
|
if (!callObject) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats();
|
updateStats();
|
||||||
|
|
||||||
|
|
@ -38,7 +36,7 @@ export const NetworkAside = () => {
|
||||||
Math.round(
|
Math.round(
|
||||||
(networkStats?.stats?.latest?.videoRecvBitsPerSecond ?? 0) / 1000
|
(networkStats?.stats?.latest?.videoRecvBitsPerSecond ?? 0) / 1000
|
||||||
),
|
),
|
||||||
[networkStats]
|
[networkStats?.stats?.latest?.videoRecvBitsPerSecond]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadKbs = useMemo(
|
const uploadKbs = useMemo(
|
||||||
|
|
@ -46,7 +44,7 @@ export const NetworkAside = () => {
|
||||||
Math.round(
|
Math.round(
|
||||||
(networkStats?.stats?.latest?.videoSendBitsPerSecond ?? 0) / 1000
|
(networkStats?.stats?.latest?.videoSendBitsPerSecond ?? 0) / 1000
|
||||||
),
|
),
|
||||||
[networkStats]
|
[networkStats?.stats?.latest?.videoSendBitsPerSecond]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!showAside || showAside !== NETWORK_ASIDE) {
|
if (!showAside || showAside !== NETWORK_ASIDE) {
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ PersonRow.propTypes = {
|
||||||
export const PeopleAside = () => {
|
export const PeopleAside = () => {
|
||||||
const { callObject } = useCallState();
|
const { callObject } = useCallState();
|
||||||
const { showAside, setShowAside } = useUIState();
|
const { showAside, setShowAside } = useUIState();
|
||||||
const { allParticipants, isOwner } = useParticipants();
|
const { participants, isOwner } = useParticipants();
|
||||||
|
|
||||||
if (!showAside || showAside !== PEOPLE_ASIDE) {
|
if (!showAside || showAside !== PEOPLE_ASIDE) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -131,7 +131,7 @@ export const PeopleAside = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
{allParticipants.map((p) => (
|
{participants.map((p) => (
|
||||||
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
|
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,30 @@
|
||||||
* into into a single audio node using the CombinedAudioTrack component
|
* into into a single audio node using the CombinedAudioTrack component
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { useCallState } from '@custom/shared/contexts/CallProvider';
|
||||||
import { useTracks } from '@custom/shared/contexts/TracksProvider';
|
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 Bowser from 'bowser';
|
||||||
import { Portal } from 'react-portal';
|
import { Portal } from 'react-portal';
|
||||||
import AudioTrack from './AudioTrack';
|
import AudioTrack from './AudioTrack';
|
||||||
import CombinedAudioTrack from './CombinedAudioTrack';
|
import CombinedAudioTrack from './CombinedAudioTrack';
|
||||||
|
|
||||||
|
|
||||||
export const Audio = () => {
|
export const Audio = () => {
|
||||||
|
const { disableAudio } = useCallState();
|
||||||
const { audioTracks } = useTracks();
|
const { audioTracks } = useTracks();
|
||||||
|
const { setShowAutoplayFailedModal } = useUIState();
|
||||||
|
|
||||||
const renderedTracks = useMemo(
|
const renderedTracks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.entries(audioTracks).reduce(
|
Object.entries(audioTracks).reduce((tracks, [id, track]) => {
|
||||||
(tracks, [id, track]) => ({ ...tracks, [id]: track }),
|
if (!disableAudio || isScreenId(id)) {
|
||||||
{}
|
tracks[id] = track;
|
||||||
),
|
}
|
||||||
[audioTracks]
|
return tracks;
|
||||||
|
}, {}),
|
||||||
|
[audioTracks, disableAudio]
|
||||||
);
|
);
|
||||||
|
|
||||||
// On iOS safari, when headphones are disconnected, all audio elements are paused.
|
// 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.
|
// To fix that, we call `play` on each audio track on all devicechange events.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const playTracks = () => {
|
const playTracks = () => {
|
||||||
document.querySelectorAll('.audioTracks audio').forEach(async (audio) => {
|
document
|
||||||
try {
|
.querySelectorAll('.audioTracks audio')
|
||||||
if (audio.paused && audio.readyState === audio.HAVE_ENOUGH_DATA) {
|
.forEach(async (audio) => {
|
||||||
await audio?.play();
|
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);
|
navigator.mediaDevices.addEventListener('devicechange', playTracks);
|
||||||
return () => {
|
return () => {
|
||||||
navigator.mediaDevices.removeEventListener('devicechange', playTracks);
|
navigator.mediaDevices.removeEventListener('devicechange', playTracks);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [setShowAutoplayFailedModal]);
|
||||||
|
|
||||||
const tracksComponent = useMemo(() => {
|
const tracksComponent = useMemo(() => {
|
||||||
const { browser } = Bowser.parse(navigator.userAgent);
|
const { browser, platform, os } = Bowser.parse(navigator.userAgent);
|
||||||
if (browser.name === 'Chrome' && parseInt(browser.version, 10) >= 92) {
|
if (
|
||||||
|
browser.name === 'Chrome' &&
|
||||||
|
parseInt(browser.version, 10) >= 92 &&
|
||||||
|
(platform.type === 'desktop' || os.name === 'Android')
|
||||||
|
) {
|
||||||
return <CombinedAudioTrack tracks={renderedTracks} />;
|
return <CombinedAudioTrack tracks={renderedTracks} />;
|
||||||
}
|
}
|
||||||
return Object.entries(renderedTracks).map(([id, track]) => (
|
return Object.entries(renderedTracks).map(([id, track]) => (
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,35 @@
|
||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const AudioTrack = ({ track }) => {
|
export const AudioTrack = ({ track }) => {
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
|
const { setShowAutoplayFailedModal } = useUIState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioRef.current) return false;
|
const audioTag = audioRef.current;
|
||||||
|
if (!audioTag) return false;
|
||||||
let playTimeout;
|
let playTimeout;
|
||||||
|
|
||||||
const handleCanPlay = () => {
|
const handleCanPlay = () => {
|
||||||
playTimeout = setTimeout(() => {
|
playTimeout = setTimeout(() => {
|
||||||
console.log('Unable to autoplay audio element');
|
setShowAutoplayFailedModal(true);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
};
|
};
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
clearTimeout(playTimeout);
|
clearTimeout(playTimeout);
|
||||||
};
|
};
|
||||||
audioRef.current.addEventListener('canplay', handleCanPlay);
|
audioTag.addEventListener('canplay', handleCanPlay);
|
||||||
audioRef.current.addEventListener('play', handlePlay);
|
audioTag.addEventListener('play', handlePlay);
|
||||||
audioRef.current.srcObject = new MediaStream([track]);
|
audioTag.srcObject = new MediaStream([track]);
|
||||||
|
|
||||||
const audioEl = audioRef.current;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
audioEl?.removeEventListener('canplay', handleCanPlay);
|
audioTag?.removeEventListener('canplay', handleCanPlay);
|
||||||
audioEl?.removeEventListener('play', handlePlay);
|
audioTag?.removeEventListener('play', handlePlay);
|
||||||
};
|
};
|
||||||
}, [track]);
|
}, [setShowAutoplayFailedModal, track]);
|
||||||
|
|
||||||
return track ? (
|
return track ? <audio autoPlay playsInline ref={audioRef} /> : null;
|
||||||
<audio autoPlay playsInline ref={audioRef}>
|
|
||||||
<track kind="captions" />
|
|
||||||
</audio>
|
|
||||||
) : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
AudioTrack.propTypes = {
|
AudioTrack.propTypes = {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useDeepCompareEffect, useDeepCompareMemo } from 'use-deep-compare';
|
import { useDeepCompareEffect, useDeepCompareMemo } from 'use-deep-compare';
|
||||||
|
|
||||||
const CombinedAudioTrack = ({ tracks }) => {
|
export const CombinedAudioTrack = ({ tracks }) => {
|
||||||
const audioEl = useRef(null);
|
const audioEl = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -25,12 +25,21 @@ const CombinedAudioTrack = ({ tracks }) => {
|
||||||
allTracks.forEach((track) => {
|
allTracks.forEach((track) => {
|
||||||
const persistentTrack = track?.persistentTrack;
|
const persistentTrack = track?.persistentTrack;
|
||||||
if (persistentTrack) {
|
if (persistentTrack) {
|
||||||
persistentTrack.addEventListener(
|
switch (persistentTrack.readyState) {
|
||||||
'ended',
|
case 'ended':
|
||||||
(ev) => stream.removeTrack(ev.target),
|
stream.removeTrack(persistentTrack);
|
||||||
{ once: true }
|
break;
|
||||||
);
|
case 'live':
|
||||||
stream.addTrack(persistentTrack);
|
persistentTrack.addEventListener(
|
||||||
|
'ended',
|
||||||
|
(ev) => {
|
||||||
|
stream.removeTrack(ev.target);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
stream.addTrack(persistentTrack);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,11 +62,7 @@ const CombinedAudioTrack = ({ tracks }) => {
|
||||||
playAudio();
|
playAudio();
|
||||||
}, [tracks, trackIds]);
|
}, [tracks, trackIds]);
|
||||||
|
|
||||||
return (
|
return <audio autoPlay playsInline ref={audioEl} />;
|
||||||
<audio autoPlay playsInline ref={audioEl}>
|
|
||||||
<track kind="captions" />
|
|
||||||
</audio>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CombinedAudioTrack.propTypes = {
|
CombinedAudioTrack.propTypes = {
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,13 @@ export const Button = forwardRef(
|
||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.small-circle {
|
||||||
|
padding: 0px;
|
||||||
|
height: 42px;
|
||||||
|
width: 42px;
|
||||||
|
border-radius: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
.button.translucent {
|
.button.translucent {
|
||||||
background: ${hexa(theme.blue.light, 0.35)};
|
background: ${hexa(theme.blue.light, 0.35)};
|
||||||
color: white;
|
color: white;
|
||||||
|
|
@ -290,6 +297,24 @@ export const Button = forwardRef(
|
||||||
opacity: 0.35;
|
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 {
|
.button.white {
|
||||||
background: white;
|
background: white;
|
||||||
border: 0px;
|
border: 0px;
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,19 @@ export const Capsule = ({ children, variant }) => (
|
||||||
<span className={classNames('capsule', variant)}>
|
<span className={classNames('capsule', variant)}>
|
||||||
{children}
|
{children}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
display: inline-flex;
|
.capsule {
|
||||||
padding: 4px 6px;
|
display: inline-flex;
|
||||||
margin: 0 6px;
|
padding: 4px 6px;
|
||||||
align-items: center;
|
margin: 0 6px;
|
||||||
line-height: 1;
|
align-items: center;
|
||||||
justify-content: center;
|
line-height: 1;
|
||||||
border-radius: 5px;
|
justify-content: center;
|
||||||
font-size: 0.75rem;
|
border-radius: 5px;
|
||||||
font-weight: var(--weight-bold);
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
font-weight: var(--weight-bold);
|
||||||
letter-spacing: 1px;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
.capsule.success {
|
.capsule.success {
|
||||||
background-color: var(--green-default);
|
background-color: var(--green-default);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,22 @@ import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export const Card = ({ children, className }) => (
|
export const Card = ({ children, className, variant }) => (
|
||||||
<div className={classNames('card', className)}>
|
<div className={classNames('card', className, variant)}>
|
||||||
{children}
|
{children}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
background: white;
|
|
||||||
box-sizing: border-box;
|
.card {
|
||||||
border-radius: var(--radius-md);
|
background: var(--reverse);
|
||||||
padding: var(--spacing-md);
|
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>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -26,7 +34,6 @@ export const CardHeader = ({ children }) => (
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
color: var(--text-default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& + :global(.card-body) {
|
& + :global(.card-body) {
|
||||||
|
|
@ -43,7 +50,9 @@ export const CardBody = ({ children }) => (
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{children}
|
{children}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
color: var(--text-mid);
|
.card-body {
|
||||||
|
color: var(--text-mid);
|
||||||
|
}
|
||||||
|
|
||||||
& + :global(.card-footer) {
|
& + :global(.card-footer) {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,15 @@ export const DeviceSelect = () => {
|
||||||
cams,
|
cams,
|
||||||
mics,
|
mics,
|
||||||
speakers,
|
speakers,
|
||||||
currentDevices,
|
currentCam,
|
||||||
setCamDevice,
|
setCurrentCam,
|
||||||
setMicDevice,
|
currentMic,
|
||||||
setSpeakersDevice,
|
setCurrentMic,
|
||||||
|
currentSpeaker,
|
||||||
|
setCurrentSpeaker,
|
||||||
} = useMediaDevices();
|
} = useMediaDevices();
|
||||||
|
|
||||||
if (!currentDevices) {
|
if (!currentCam && !currentMic && !currentSpeaker) {
|
||||||
return <div>Loading devices...</div>;
|
return <div>Loading devices...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,9 +24,9 @@ export const DeviceSelect = () => {
|
||||||
<>
|
<>
|
||||||
<Field label="Select camera:">
|
<Field label="Select camera:">
|
||||||
<SelectInput
|
<SelectInput
|
||||||
onChange={(e) => setCamDevice(cams[e.target.value])}
|
onChange={(e) => setCurrentCam(cams[e.target.value])}
|
||||||
value={cams.findIndex(
|
value={cams.findIndex(
|
||||||
(i) => i.deviceId === currentDevices.camera.deviceId
|
(i) => i.deviceId === currentCam.deviceId
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cams.map(({ deviceId, label }, i) => (
|
{cams.map(({ deviceId, label }, i) => (
|
||||||
|
|
@ -37,9 +39,9 @@ export const DeviceSelect = () => {
|
||||||
|
|
||||||
<Field label="Select microphone:">
|
<Field label="Select microphone:">
|
||||||
<SelectInput
|
<SelectInput
|
||||||
onChange={(e) => setMicDevice(mics[e.target.value])}
|
onChange={(e) => setCurrentMic(mics[e.target.value])}
|
||||||
value={mics.findIndex(
|
value={mics.findIndex(
|
||||||
(i) => i.deviceId === currentDevices.mic.deviceId
|
(i) => i.deviceId === currentMic.deviceId
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mics.map(({ deviceId, label }, i) => (
|
{mics.map(({ deviceId, label }, i) => (
|
||||||
|
|
@ -56,9 +58,9 @@ export const DeviceSelect = () => {
|
||||||
{speakers.length > 0 && (
|
{speakers.length > 0 && (
|
||||||
<Field label="Select speakers:">
|
<Field label="Select speakers:">
|
||||||
<SelectInput
|
<SelectInput
|
||||||
onChange={(e) => setSpeakersDevice(speakers[e.target.value])}
|
onChange={(e) => setCurrentSpeaker(speakers[e.target.value])}
|
||||||
value={speakers.findIndex(
|
value={speakers.findIndex(
|
||||||
(i) => i.deviceId === currentDevices.speaker.deviceId
|
(i) => i.deviceId === currentSpeaker.deviceId
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{speakers.map(({ deviceId, label }, i) => (
|
{speakers.map(({ deviceId, label }, i) => (
|
||||||
|
|
|
||||||
|
|
@ -105,17 +105,12 @@ export const HairCheck = () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasError = useMemo(() => {
|
const hasError = useMemo(() => {
|
||||||
if (
|
return !(!deviceState ||
|
||||||
!deviceState ||
|
|
||||||
[
|
[
|
||||||
DEVICE_STATE_LOADING,
|
DEVICE_STATE_LOADING,
|
||||||
DEVICE_STATE_PENDING,
|
DEVICE_STATE_PENDING,
|
||||||
DEVICE_STATE_GRANTED,
|
DEVICE_STATE_GRANTED,
|
||||||
].includes(deviceState)
|
].includes(deviceState));
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, [deviceState]);
|
}, [deviceState]);
|
||||||
|
|
||||||
const camErrorVerbose = useMemo(() => {
|
const camErrorVerbose = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ const InputContainer = ({ children, prefix, className }) => (
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.dark :global(input)::-moz-placeholder {
|
.dark :global(input)::-moz-placeholder {
|
||||||
ccolor: var(--text-mid);
|
color: var(--text-mid);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.dark :global(input)::-ms-input-placeholder {
|
.dark :global(input)::-ms-input-placeholder {
|
||||||
|
|
@ -126,6 +126,12 @@ const InputContainer = ({ children, prefix, className }) => (
|
||||||
border: 0px;
|
border: 0px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border :global(input) {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--reverse);
|
||||||
|
color: var(--reverse);
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ export const ParticipantBar = ({
|
||||||
const maybePromoteActiveSpeaker = () => {
|
const maybePromoteActiveSpeaker = () => {
|
||||||
const fixedOther = fixed.find((f) => !f.isLocal);
|
const fixedOther = fixed.find((f) => !f.isLocal);
|
||||||
// Ignore when speaker is already at first position or component unmounted
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { memo, useEffect, useState, useRef } from 'react';
|
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 { ReactComponent as IconMicMute } from '@custom/shared/icons/mic-off-sm.svg';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { DEFAULT_ASPECT_RATIO } from '../../constants';
|
import { DEFAULT_ASPECT_RATIO } from '../../constants';
|
||||||
import Video from './Video';
|
import { Video } from './Video';
|
||||||
import { ReactComponent as Avatar } from './avatar.svg';
|
import { ReactComponent as Avatar } from './avatar.svg';
|
||||||
|
|
||||||
const SM_TILE_MAX_WIDTH = 300;
|
const SM_TILE_MAX_WIDTH = 300;
|
||||||
|
|
@ -21,7 +21,7 @@ export const Tile = memo(
|
||||||
onVideoResize,
|
onVideoResize,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const videoTrack = useVideoTrack(participant);
|
const videoTrack = useVideoTrack(participant.id);
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const tileRef = useRef(null);
|
const tileRef = useRef(null);
|
||||||
const [tileWidth, setTileWidth] = useState(0);
|
const [tileWidth, setTileWidth] = useState(0);
|
||||||
|
|
@ -99,8 +99,9 @@ export const Tile = memo(
|
||||||
{videoTrack ? (
|
{videoTrack ? (
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
fit={videoFit}
|
||||||
|
isScreen={participant.isScreenshare}
|
||||||
participantId={participant?.id}
|
participantId={participant?.id}
|
||||||
videoTrack={videoTrack}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
showAvatar && (
|
showAvatar && (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,30 @@
|
||||||
import React, { useMemo, forwardRef, memo, useEffect } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import Bowser from 'bowser';
|
import Bowser from 'bowser';
|
||||||
import PropTypes from 'prop-types';
|
import classNames from 'classnames';
|
||||||
import { shallowEqualObjects } from 'shallow-equal';
|
|
||||||
|
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 isChrome92 = useMemo(() => {
|
||||||
const { browser, platform, os } = Bowser.parse(navigator.userAgent);
|
const { browser, platform, os } = Bowser.parse(navigator.userAgent);
|
||||||
return (
|
return (
|
||||||
|
|
@ -19,43 +35,114 @@ export const Video = memo(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Umount
|
* Determine if video needs to be mirrored.
|
||||||
* Note: nullify src to ensure media object is not counted
|
*/
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const video = videoEl.current;
|
const video = videoEl.current;
|
||||||
if (!video) return false;
|
if (!video) return;
|
||||||
// clean up when video renders for different participant
|
const handleCanPlay = () => {
|
||||||
video.srcObject = null;
|
if (!video.paused) return;
|
||||||
if (isChrome92) video.load();
|
video.play();
|
||||||
return () => {
|
|
||||||
// clean up when unmounted
|
|
||||||
video.srcObject = null;
|
|
||||||
if (isChrome92) video.load();
|
|
||||||
};
|
};
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const video = videoEl.current;
|
const video = videoEl.current;
|
||||||
if (!video || !videoTrack) return;
|
if (!video || !videoTrack) return;
|
||||||
video.srcObject = new MediaStream([videoTrack]);
|
video.srcObject = new MediaStream([videoTrack]);
|
||||||
if (isChrome92) video.load();
|
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} />;
|
return (
|
||||||
}),
|
<>
|
||||||
(p, n) => shallowEqualObjects(p, n)
|
<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.displayName = 'Video';
|
||||||
|
|
||||||
Video.propTypes = {
|
|
||||||
videoTrack: PropTypes.any,
|
|
||||||
mirrored: PropTypes.bool,
|
|
||||||
participantId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Video;
|
|
||||||
|
|
|
||||||
|
|
@ -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 { NETWORK_ASIDE } from '@custom/shared/components/Aside/NetworkAside';
|
||||||
import { PEOPLE_ASIDE } from '@custom/shared/components/Aside/PeopleAside';
|
import { PEOPLE_ASIDE } from '@custom/shared/components/Aside/PeopleAside';
|
||||||
import Button from '@custom/shared/components/Button';
|
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';
|
import { Tray, TrayButton } from './Tray';
|
||||||
|
|
||||||
export const BasicTray = () => {
|
export const BasicTray = () => {
|
||||||
|
const ref = useRef(null);
|
||||||
const responsive = useResponsive();
|
const responsive = useResponsive();
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const { callObject, leave } = useCallState();
|
const { callObject, leave } = useCallState();
|
||||||
|
|
@ -35,6 +36,18 @@ export const BasicTray = () => {
|
||||||
return callObject.setLocalAudio(newState);
|
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 (
|
return (
|
||||||
<Tray className="tray">
|
<Tray className="tray">
|
||||||
<TrayButton
|
<TrayButton
|
||||||
|
|
@ -52,7 +65,7 @@ export const BasicTray = () => {
|
||||||
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
{responsive.isMobile() && showMore && (
|
{responsive.isMobile() && showMore && (
|
||||||
<div className="more-options">
|
<div className="more-options" ref={ref}>
|
||||||
<Button
|
<Button
|
||||||
className="translucent"
|
className="translucent"
|
||||||
onClick={() => openModal(DEVICE_MODAL)}
|
onClick={() => openModal(DEVICE_MODAL)}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TrayButton } from '@custom/shared/components/Tray';
|
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 IconMicOff } from '@custom/shared/icons/mic-off-md.svg';
|
||||||
import { ReactComponent as IconMicOn } from '@custom/shared/icons/mic-on-md.svg';
|
import { ReactComponent as IconMicOn } from '@custom/shared/icons/mic-on-md.svg';
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export const TrayMicButton = ({ isMuted, onClick }) => {
|
export const TrayMicButton = ({ isMuted, onClick }) => {
|
||||||
const audioLevel = useAudioLevel('local');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TrayButton label="Mic" onClick={onClick} orange={isMuted}>
|
<TrayButton label="Mic" onClick={onClick} orange={isMuted}>
|
||||||
{isMuted ? <IconMicOff /> : <IconMicOn />}
|
{isMuted ? <IconMicOff /> : <IconMicOn />}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import DailyIframe from '@daily-co/daily-js';
|
||||||
import Bowser from 'bowser';
|
import Bowser from 'bowser';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
ACCESS_STATE_LOBBY,
|
ACCESS_STATE_LOBBY,
|
||||||
|
|
@ -30,7 +32,12 @@ export const CallProvider = ({
|
||||||
room,
|
room,
|
||||||
token = '',
|
token = '',
|
||||||
subscribeToTracksAutomatically = true,
|
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 [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
|
||||||
const [showLocalVideo, setShowLocalVideo] = useState(true);
|
const [showLocalVideo, setShowLocalVideo] = useState(true);
|
||||||
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
|
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
|
||||||
|
|
@ -52,7 +59,14 @@ export const CallProvider = ({
|
||||||
if (!daily) return;
|
if (!daily) return;
|
||||||
const updateRoomConfigState = async () => {
|
const updateRoomConfigState = async () => {
|
||||||
const roomConfig = await daily.room();
|
const roomConfig = await daily.room();
|
||||||
|
const isOob = !!roomConfig.config?.owner_only_broadcast;
|
||||||
|
const owner = roomConfig.tokenConfig?.is_owner;
|
||||||
const config = roomConfig?.config;
|
const config = roomConfig?.config;
|
||||||
|
|
||||||
|
setRoomInfo(roomConfig);
|
||||||
|
|
||||||
|
const fullUI = !isOob || (isOob && owner);
|
||||||
|
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
if (config.exp) {
|
if (config.exp) {
|
||||||
|
|
@ -76,6 +90,12 @@ export const CallProvider = ({
|
||||||
roomConfig?.tokenConfig?.start_cloud_recording ?? false
|
roomConfig?.tokenConfig?.start_cloud_recording ?? false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setEnableScreenShare(
|
||||||
|
fullUI &&
|
||||||
|
(roomConfig?.tokenConfig?.enable_screenshare ??
|
||||||
|
roomConfig?.config?.enable_screenshare) &&
|
||||||
|
DailyIframe.supportedBrowser().supportsScreenShare
|
||||||
|
);
|
||||||
};
|
};
|
||||||
updateRoomConfigState();
|
updateRoomConfigState();
|
||||||
}, [state, daily]);
|
}, [state, daily]);
|
||||||
|
|
@ -103,6 +123,15 @@ export const CallProvider = ({
|
||||||
setPreJoinNonAuthorized(requiresPermission && !token);
|
setPreJoinNonAuthorized(requiresPermission && !token);
|
||||||
}, [state, daily, 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 (
|
return (
|
||||||
<CallContext.Provider
|
<CallContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
@ -115,13 +144,19 @@ export const CallProvider = ({
|
||||||
showLocalVideo,
|
showLocalVideo,
|
||||||
roomExp,
|
roomExp,
|
||||||
enableRecording,
|
enableRecording,
|
||||||
|
enableScreenShare,
|
||||||
|
enableJoinSound,
|
||||||
videoQuality,
|
videoQuality,
|
||||||
setVideoQuality,
|
setVideoQuality,
|
||||||
|
roomInfo,
|
||||||
|
setRoomInfo,
|
||||||
setBandwidth,
|
setBandwidth,
|
||||||
setRedirectOnLeave,
|
setRedirectOnLeave,
|
||||||
setShowLocalVideo,
|
setShowLocalVideo,
|
||||||
|
setEnableScreenShare,
|
||||||
startCloudRecording,
|
startCloudRecording,
|
||||||
subscribeToTracksAutomatically,
|
subscribeToTracksAutomatically,
|
||||||
|
setEnableJoinSound
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { createContext, useContext } from 'react';
|
import React, { createContext, useContext, useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useCallState } from './CallProvider';
|
import { useCallState } from './CallProvider';
|
||||||
|
|
@ -12,33 +12,72 @@ export const MediaDeviceProvider = ({ children }) => {
|
||||||
const { localParticipant } = useParticipants();
|
const { localParticipant } = useParticipants();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
cams,
|
|
||||||
mics,
|
|
||||||
speakers,
|
|
||||||
camError,
|
camError,
|
||||||
micError,
|
cams,
|
||||||
currentDevices,
|
currentCam,
|
||||||
|
currentMic,
|
||||||
|
currentSpeaker,
|
||||||
deviceState,
|
deviceState,
|
||||||
setMicDevice,
|
micError,
|
||||||
setCamDevice,
|
mics,
|
||||||
setSpeakersDevice,
|
refreshDevices,
|
||||||
|
setCurrentCam,
|
||||||
|
setCurrentMic,
|
||||||
|
setCurrentSpeaker,
|
||||||
|
speakers,
|
||||||
} = useDevices(callObject);
|
} = 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 (
|
return (
|
||||||
<MediaDeviceContext.Provider
|
<MediaDeviceContext.Provider
|
||||||
value={{
|
value={{
|
||||||
cams,
|
|
||||||
mics,
|
|
||||||
speakers,
|
|
||||||
camError,
|
camError,
|
||||||
micError,
|
cams,
|
||||||
currentDevices,
|
currentCam,
|
||||||
|
currentMic,
|
||||||
|
currentSpeaker,
|
||||||
deviceState,
|
deviceState,
|
||||||
isCamMuted: localParticipant.isCamMuted,
|
isCamMuted: localParticipant.isCamMuted,
|
||||||
isMicMuted: localParticipant.isMicMuted,
|
isMicMuted: localParticipant.isMicMuted,
|
||||||
setMicDevice,
|
micError,
|
||||||
setCamDevice,
|
mics,
|
||||||
setSpeakersDevice,
|
refreshDevices,
|
||||||
|
setCurrentCam: selectCamera,
|
||||||
|
setCurrentMic: selectMic,
|
||||||
|
setCurrentSpeaker: selectSpeaker,
|
||||||
|
speakers,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,36 @@
|
||||||
import React, {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useReducer,
|
useReducer,
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import { sortByKey } from '@custom/shared/lib/sortByKey';
|
||||||
useUIState,
|
import { useNetworkState } from '../hooks/useNetworkState';
|
||||||
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 { useCallState } from './CallProvider';
|
import { useCallState } from './CallProvider';
|
||||||
|
import { useUIState } from './UIStateProvider';
|
||||||
import {
|
import {
|
||||||
initialParticipantsState,
|
initialParticipantsState,
|
||||||
isLocalId,
|
isLocalId,
|
||||||
ACTIVE_SPEAKER,
|
|
||||||
PARTICIPANT_JOINED,
|
|
||||||
PARTICIPANT_LEFT,
|
|
||||||
PARTICIPANT_UPDATED,
|
|
||||||
participantsReducer,
|
participantsReducer,
|
||||||
SWAP_POSITION,
|
|
||||||
} from './participantsState';
|
} from './participantsState';
|
||||||
|
|
||||||
export const ParticipantsContext = createContext();
|
export const ParticipantsContext = createContext(null);
|
||||||
|
|
||||||
export const ParticipantsProvider = ({ children }) => {
|
export const ParticipantsProvider = ({ children }) => {
|
||||||
const { callObject, videoQuality, networkState } = useCallState();
|
const { isMobile, pinnedId, viewMode } = useUIState();
|
||||||
const [state, dispatch] = useReducer(
|
const {
|
||||||
participantsReducer,
|
broadcast,
|
||||||
initialParticipantsState
|
broadcastRole,
|
||||||
);
|
callObject: daily,
|
||||||
const { viewMode } = useUIState();
|
videoQuality,
|
||||||
const [
|
} = useCallState();
|
||||||
participantMarkedForRemoval,
|
const [state, dispatch] = useReducer(participantsReducer, initialParticipantsState);
|
||||||
setParticipantMarkedForRemoval,
|
const [participantMarkedForRemoval, setParticipantMarkedForRemoval] = useState(null);
|
||||||
] = useState(null);
|
|
||||||
|
const { threshold } = useNetworkState();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ALL participants (incl. shared screens) in a convenient array
|
* 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
|
* Only return participants that should be visible in the call
|
||||||
*/
|
*/
|
||||||
const participants = useMemo(() => state.participants, [state.participants]);
|
const participants = useMemo(() => {
|
||||||
|
if (broadcast) {
|
||||||
/**
|
return state.participants.filter((p) => p?.isOwner);
|
||||||
* Array of participant IDs
|
}
|
||||||
*/
|
return state.participants;
|
||||||
const participantIds = useMemo(
|
}, [broadcast, state.participants]);
|
||||||
() => participants.map((p) => p.id).join(','),
|
|
||||||
[participants]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of participants, who are not a shared screen
|
* The number of participants, who are not a shared screen
|
||||||
|
|
@ -106,28 +87,26 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
*/
|
*/
|
||||||
const currentSpeaker = useMemo(() => {
|
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
|
* The activeParticipant only updates to a new active participant so
|
||||||
* if everyone else is muted when AP leaves, the value will be stale.
|
* if everyone else is muted when AP leaves, the value will be stale.
|
||||||
*/
|
*/
|
||||||
const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
|
const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
|
||||||
if (isPresent) {
|
const pinned = participants.find((p) => p?.id === pinnedId);
|
||||||
return activeParticipant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (pinned) return pinned;
|
||||||
* If the activeParticipant has left, calculate the remaining displayable participants
|
|
||||||
*/
|
const displayableParticipants = participants.filter((p) =>
|
||||||
const displayableParticipants = participants.filter((p) => !p?.isLocal);
|
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 (
|
if (
|
||||||
|
!isPresent &&
|
||||||
displayableParticipants.length > 0 &&
|
displayableParticipants.length > 0 &&
|
||||||
displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate)
|
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 (
|
return (
|
||||||
displayableParticipants.find((p) => !p.isCamMuted) ??
|
displayableParticipants.find((p) => !p.isCamMuted) ??
|
||||||
displayableParticipants?.[0]
|
displayableParticipants?.[0]
|
||||||
|
|
@ -135,26 +114,56 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = displayableParticipants
|
const sorted = displayableParticipants
|
||||||
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
|
.sort(sortByKey('lastActiveDate'))
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
const lastActiveSpeaker = sorted?.[0];
|
const fallback = broadcastRole === 'attendee' ? null : localParticipant;
|
||||||
|
|
||||||
return lastActiveSpeaker || localParticipant;
|
return isPresent ? activeParticipant : sorted?.[0] ?? fallback;
|
||||||
}, [activeParticipant, localParticipant, participants]);
|
}, [
|
||||||
|
activeParticipant,
|
||||||
|
broadcastRole,
|
||||||
|
isMobile,
|
||||||
|
localParticipant,
|
||||||
|
participants,
|
||||||
|
pinnedId,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen shares
|
* Screen shares
|
||||||
*/
|
*/
|
||||||
const screens = useMemo(
|
const screens = useMemo(() => state?.screens, [state?.screens]);
|
||||||
() => allParticipants.filter(({ isScreenshare }) => isScreenshare),
|
|
||||||
[allParticipants]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The local participant's name
|
* 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);
|
const [muteNewParticipants, setMuteNewParticipants] = useState(false);
|
||||||
|
|
||||||
|
|
@ -166,95 +175,81 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
(p) => !p.isLocal && !p.isMicMuted
|
(p) => !p.isLocal && !p.isMicMuted
|
||||||
);
|
);
|
||||||
if (!unmutedParticipants.length) return;
|
if (!unmutedParticipants.length) return;
|
||||||
const result = unmutedParticipants.reduce(
|
daily.updateParticipants(
|
||||||
(o, p) => ({ ...o[p.id], setAudio: false }),
|
unmutedParticipants.reduce((o, p) => {
|
||||||
{}
|
o[p.id] = {
|
||||||
|
setAudio: false,
|
||||||
|
};
|
||||||
|
return o;
|
||||||
|
}, {})
|
||||||
);
|
);
|
||||||
callObject.updateParticipants(result);
|
|
||||||
},
|
},
|
||||||
[callObject, localParticipant, participants]
|
[daily, localParticipant, participants]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const handleParticipantJoined = useCallback(() => {
|
||||||
* 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;
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SWAP_POSITION,
|
type: 'JOINED_MEETING',
|
||||||
id1,
|
participant: daily.participants().local,
|
||||||
id2,
|
|
||||||
});
|
});
|
||||||
};
|
}, [daily]);
|
||||||
|
|
||||||
const handleNewParticipantsState = useCallback(
|
const handleNewParticipantsState = useCallback(
|
||||||
(event = null) => {
|
(event = null) => {
|
||||||
switch (event?.action) {
|
switch (event?.action) {
|
||||||
case 'participant-joined':
|
case 'participant-joined':
|
||||||
dispatch({
|
dispatch({
|
||||||
type: PARTICIPANT_JOINED,
|
type: 'PARTICIPANT_JOINED',
|
||||||
participant: event.participant,
|
participant: event.participant,
|
||||||
});
|
});
|
||||||
|
if (muteNewParticipants && daily) {
|
||||||
|
daily.updateParticipant(event.participant.session_id, {
|
||||||
|
setAudio: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'participant-updated':
|
case 'participant-updated':
|
||||||
dispatch({
|
dispatch({
|
||||||
type: PARTICIPANT_UPDATED,
|
type: 'PARTICIPANT_UPDATED',
|
||||||
participant: event.participant,
|
participant: event.participant,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'participant-left':
|
case 'participant-left':
|
||||||
dispatch({
|
dispatch({
|
||||||
type: PARTICIPANT_LEFT,
|
type: 'PARTICIPANT_LEFT',
|
||||||
participant: event.participant,
|
participant: event.participant,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[daily, dispatch, muteNewParticipants]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Start listening for participant changes, when the callObject is set.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
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 = [
|
return () => {
|
||||||
'joined-meeting',
|
daily.off('participant-joined', handleParticipantJoined);
|
||||||
'participant-joined',
|
daily.off('participant-joined', handleNewParticipantsState);
|
||||||
'participant-updated',
|
daily.off('participant-updated', handleNewParticipantsState);
|
||||||
'participant-left',
|
daily.off('participant-left', handleNewParticipantsState);
|
||||||
];
|
};
|
||||||
|
}, [daily, handleNewParticipantsState, handleParticipantJoined]);
|
||||||
|
|
||||||
// Use initial state
|
const participantIds = useMemo(
|
||||||
handleNewParticipantsState();
|
() => 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(() => {
|
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 = {};
|
const receiveSettings = {};
|
||||||
|
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
|
|
@ -262,19 +257,16 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// weak or bad network
|
// weak or bad network
|
||||||
([VIDEO_QUALITY_LOW, VIDEO_QUALITY_VERY_LOW].includes(networkState) &&
|
(['low', 'very-low'].includes(threshold) && videoQuality === 'auto') ||
|
||||||
videoQuality === VIDEO_QUALITY_AUTO) ||
|
|
||||||
// Low quality or Bandwidth saver mode enabled
|
// Low quality or Bandwidth saver mode enabled
|
||||||
[VIDEO_QUALITY_BANDWIDTH_SAVER, VIDEO_QUALITY_LOW].includes(
|
['bandwidth-saver', 'low'].includes(videoQuality)
|
||||||
videoQuality
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
receiveSettings[id] = { video: { layer: 0 } };
|
receiveSettings[id] = { video: { layer: 0 } };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Speaker view settings based on speaker status or pinned user
|
// Speaker view settings based on speaker status or pinned user
|
||||||
if (viewMode === VIEW_MODE_SPEAKER) {
|
if (viewMode === 'speaker') {
|
||||||
if (currentSpeaker?.id === id) {
|
if (currentSpeaker?.id === id) {
|
||||||
receiveSettings[id] = { video: { layer: 2 } };
|
receiveSettings[id] = { video: { layer: 2 } };
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -283,13 +275,15 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid view settings are handled separately in GridView
|
// Grid view settings are handled separately in GridView
|
||||||
|
// Mobile view settings are handled separately in MobileCall
|
||||||
});
|
});
|
||||||
callObject.updateReceiveSettings(receiveSettings);
|
|
||||||
|
daily.updateReceiveSettings(receiveSettings);
|
||||||
}, [
|
}, [
|
||||||
currentSpeaker?.id,
|
currentSpeaker?.id,
|
||||||
callObject,
|
daily,
|
||||||
networkState,
|
|
||||||
participantIds,
|
participantIds,
|
||||||
|
threshold,
|
||||||
videoQuality,
|
videoQuality,
|
||||||
viewMode,
|
viewMode,
|
||||||
]);
|
]);
|
||||||
|
|
@ -299,39 +293,38 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
}, [setBandWidthControls]);
|
}, [setBandWidthControls]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callObject) return false;
|
if (!daily) return;
|
||||||
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
|
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
|
||||||
/**
|
/**
|
||||||
* Ignore active-speaker-change events for the local user.
|
* Ignore active-speaker-change events for the local user.
|
||||||
* Our UX doesn't ever highlight the local user as the active speaker.
|
* 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;
|
const activeSpeakerId = activeSpeaker?.peerId;
|
||||||
if (localId === activeSpeakerId) return;
|
if (localId === activeSpeakerId) return;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIVE_SPEAKER,
|
type: 'ACTIVE_SPEAKER',
|
||||||
id: activeSpeakerId,
|
id: activeSpeakerId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
callObject.on('active-speaker-change', handleActiveSpeakerChange);
|
daily.on('active-speaker-change', handleActiveSpeakerChange);
|
||||||
return () =>
|
return () =>
|
||||||
callObject.off('active-speaker-change', handleActiveSpeakerChange);
|
daily.off('active-speaker-change', handleActiveSpeakerChange);
|
||||||
}, [callObject]);
|
}, [daily]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParticipantsContext.Provider
|
<ParticipantsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
activeParticipant,
|
|
||||||
allParticipants,
|
allParticipants,
|
||||||
currentSpeaker,
|
currentSpeaker,
|
||||||
localParticipant,
|
localParticipant,
|
||||||
|
muteAll,
|
||||||
|
muteNewParticipants,
|
||||||
participantCount,
|
participantCount,
|
||||||
participantMarkedForRemoval,
|
participantMarkedForRemoval,
|
||||||
participants,
|
participants,
|
||||||
screens,
|
screens,
|
||||||
muteNewParticipants,
|
|
||||||
muteAll,
|
|
||||||
setParticipantMarkedForRemoval,
|
setParticipantMarkedForRemoval,
|
||||||
setUsername,
|
setUsername,
|
||||||
swapParticipantPosition,
|
swapParticipantPosition,
|
||||||
|
|
@ -344,8 +337,4 @@ export const ParticipantsProvider = ({ children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ParticipantsProvider.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useParticipants = () => useContext(ParticipantsContext);
|
export const useParticipants = () => useContext(ParticipantsContext);
|
||||||
|
|
@ -1,37 +1,29 @@
|
||||||
/* global rtcpeers */
|
import {
|
||||||
|
|
||||||
import React, {
|
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { sortByKey } from '@custom/shared/lib/sortByKey';
|
||||||
import deepEqual from 'fast-deep-equal';
|
import deepEqual from 'fast-deep-equal';
|
||||||
import PropTypes from 'prop-types';
|
import { useDeepCompareCallback } from 'use-deep-compare';
|
||||||
import { useDeepCompareEffect } from 'use-deep-compare';
|
|
||||||
import { sortByKey } from '../lib/sortByKey';
|
|
||||||
import { useCallState } from './CallProvider';
|
import { useCallState } from './CallProvider';
|
||||||
import { useParticipants } from './ParticipantsProvider';
|
import { useParticipants } from './ParticipantsProvider';
|
||||||
import { isLocalId, isScreenId } from './participantsState';
|
import { useUIState } from './UIStateProvider';
|
||||||
import {
|
import { getScreenId, isLocalId, isScreenId } from './participantsState';
|
||||||
initialTracksState,
|
import { initialTracksState, tracksReducer } from './tracksState';
|
||||||
REMOVE_TRACKS,
|
|
||||||
TRACK_STARTED,
|
|
||||||
TRACK_STOPPED,
|
|
||||||
TRACK_VIDEO_UPDATED,
|
|
||||||
TRACK_AUDIO_UPDATED,
|
|
||||||
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,
|
* If the remote participant count passes this threshold,
|
||||||
* cam subscriptions are defined by UI view modes.
|
* cam subscriptions are defined by UI view modes.
|
||||||
*/
|
*/
|
||||||
|
|
@ -40,15 +32,17 @@ const SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD = 9;
|
||||||
const TracksContext = createContext(null);
|
const TracksContext = createContext(null);
|
||||||
|
|
||||||
export const TracksProvider = ({ children }) => {
|
export const TracksProvider = ({ children }) => {
|
||||||
const { callObject, subscribeToTracksAutomatically } = useCallState();
|
const { callObject: daily, optimizeLargeCalls } = useCallState();
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
|
const { viewMode } = useUIState();
|
||||||
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
|
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
|
||||||
|
const [maxCamSubscriptions, setMaxCamSubscriptions] = useState(null);
|
||||||
|
|
||||||
const recentSpeakerIds = useMemo(
|
const recentSpeakerIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
participants
|
participants
|
||||||
.filter((p) => Boolean(p.lastActiveDate) && !p.isLocal)
|
.filter((p) => Boolean(p.lastActiveDate))
|
||||||
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
|
.sort(sortByKey('lastActiveDate'))
|
||||||
.slice(-MAX_RECENT_SPEAKER_COUNT)
|
.slice(-MAX_RECENT_SPEAKER_COUNT)
|
||||||
.map((p) => p.id)
|
.map((p) => p.id)
|
||||||
.reverse(),
|
.reverse(),
|
||||||
|
|
@ -62,40 +56,51 @@ export const TracksProvider = ({ children }) => {
|
||||||
|
|
||||||
const subscribeToCam = useCallback(
|
const subscribeToCam = useCallback(
|
||||||
(id) => {
|
(id) => {
|
||||||
// Ignore undefined, local or screenshare.
|
/**
|
||||||
|
* Ignore undefined, local or screenshare.
|
||||||
|
*/
|
||||||
if (!id || isLocalId(id) || isScreenId(id)) return;
|
if (!id || isLocalId(id) || isScreenId(id)) return;
|
||||||
callObject.updateParticipant(id, {
|
daily.updateParticipant(id, {
|
||||||
setSubscribedTracks: { video: true },
|
setSubscribedTracks: { video: true },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[callObject]
|
[daily]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates cam subscriptions based on passed subscribedIds and stagedIds.
|
* 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(
|
const updateCamSubscriptions = useCallback(
|
||||||
(subscribedIds, stagedIds = []) => {
|
(subscribedIds, stagedIds = []) => {
|
||||||
if (!callObject) return;
|
if (!daily) return;
|
||||||
|
|
||||||
// If total number of remote participants is less than a threshold, simply
|
// If total number of remote participants is less than a threshold, simply
|
||||||
// stage all remote cams that aren't already marked for subscription.
|
// stage all remote cams that aren't already marked for subscription.
|
||||||
// Otherwise, honor the provided stagedIds, with recent speakers appended
|
// Otherwise, honor the provided stagedIds, with recent speakers appended
|
||||||
// who aren't already marked for subscription.
|
// who aren't already marked for subscription.
|
||||||
const stagedIdsFiltered =
|
if (
|
||||||
remoteParticipantIds.length <= SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD
|
remoteParticipantIds.length <= SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD
|
||||||
? remoteParticipantIds.filter((id) => !subscribedIds.includes(id))
|
) {
|
||||||
: [
|
stagedIds = remoteParticipantIds.filter(
|
||||||
...stagedIds,
|
(id) => !subscribedIds.includes(id)
|
||||||
...recentSpeakerIds.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
|
// Assemble updates to get to desired cam subscriptions
|
||||||
const updates = remoteParticipantIds.reduce((u, id) => {
|
const updates = remoteParticipantIds.reduce((u, id) => {
|
||||||
let desiredSubscription;
|
let desiredSubscription;
|
||||||
const currentSubscription =
|
const currentSubscription =
|
||||||
callObject.participants()?.[id]?.tracks?.video?.subscribed;
|
daily.participants()?.[id]?.tracks?.video?.subscribed;
|
||||||
|
|
||||||
// Ignore undefined, local or screenshare participant ids
|
// Ignore undefined, local or screenshare participant ids
|
||||||
if (!id || isLocalId(id) || isScreenId(id)) return u;
|
if (!id || isLocalId(id) || isScreenId(id)) return u;
|
||||||
|
|
@ -104,7 +109,7 @@ export const TracksProvider = ({ children }) => {
|
||||||
// subscribed, staged, or unsubscribed
|
// subscribed, staged, or unsubscribed
|
||||||
if (subscribedIds.includes(id)) {
|
if (subscribedIds.includes(id)) {
|
||||||
desiredSubscription = true;
|
desiredSubscription = true;
|
||||||
} else if (stagedIdsFiltered.includes(id)) {
|
} else if (stagedIds.includes(id)) {
|
||||||
desiredSubscription = 'staged';
|
desiredSubscription = 'staged';
|
||||||
} else {
|
} else {
|
||||||
desiredSubscription = false;
|
desiredSubscription = false;
|
||||||
|
|
@ -116,9 +121,6 @@ export const TracksProvider = ({ children }) => {
|
||||||
|
|
||||||
u[id] = {
|
u[id] = {
|
||||||
setSubscribedTracks: {
|
setSubscribedTracks: {
|
||||||
audio: true,
|
|
||||||
screenAudio: true,
|
|
||||||
screenVideo: true,
|
|
||||||
video: desiredSubscription,
|
video: desiredSubscription,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -126,110 +128,126 @@ export const TracksProvider = ({ children }) => {
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) return;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!callObject) return false;
|
if (!daily) return;
|
||||||
|
/**
|
||||||
const trackStoppedQueue = [];
|
* A little throttling as we want daily-js to have some room to breathe ☺️
|
||||||
|
*/
|
||||||
const handleTrackStarted = ({ participant, track }) => {
|
const timeout = setTimeout(() => {
|
||||||
/**
|
const participants = daily.participants();
|
||||||
* If track for participant was recently stopped, remove it from queue,
|
const updates = remoteParticipantIds.reduce((u, id) => {
|
||||||
* so we don't run into a stale state
|
// Ignore undefined, local or screenshare participant ids
|
||||||
*/
|
if (!id || isLocalId(id) || isScreenId(id)) return u;
|
||||||
const stoppingIdx = trackStoppedQueue.findIndex(
|
const isSpeaker = recentSpeakerIds.includes(id);
|
||||||
([p, t]) =>
|
const hasSubscribed = participants[id]?.tracks?.audio?.subscribed;
|
||||||
p.session_id === participant.session_id && t.kind === track.kind
|
const shouldSubscribe = optimizeLargeCalls ? isSpeaker : true;
|
||||||
);
|
/**
|
||||||
if (stoppingIdx >= 0) {
|
* In optimized calls:
|
||||||
trackStoppedQueue.splice(stoppingIdx, 1);
|
* - subscribe to speakers we're not subscribed to, yet
|
||||||
}
|
* - unsubscribe from non-speakers we're subscribed to
|
||||||
dispatch({
|
* In non-optimized calls:
|
||||||
type: TRACK_STARTED,
|
* - subscribe to all who we're not to subscribed to, yet
|
||||||
participant,
|
*/
|
||||||
track,
|
if (
|
||||||
});
|
(!hasSubscribed && shouldSubscribe) ||
|
||||||
};
|
(hasSubscribed && !shouldSubscribe)
|
||||||
|
) {
|
||||||
const trackStoppedBatchInterval = setInterval(() => {
|
u[id] = {
|
||||||
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] = {
|
|
||||||
setSubscribedTracks: {
|
setSubscribedTracks: {
|
||||||
audio: true,
|
audio: shouldSubscribe,
|
||||||
screenAudio: true,
|
|
||||||
screenVideo: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (topology === 'peer') {
|
return u;
|
||||||
o[id] = { setSubscribedTracks: true };
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
}, {});
|
}, {});
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
if (!subscribeToTracksAutomatically && Object.keys(updates).length0) {
|
daily.updateParticipants(updates);
|
||||||
callObject.updateParticipants(updates);
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
callObject.on('track-started', handleTrackStarted);
|
|
||||||
callObject.on('track-stopped', handleTrackStopped);
|
|
||||||
callObject.on('participant-joined', handleParticipantJoined);
|
|
||||||
callObject.on('participant-left', handleParticipantLeft);
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(joinBatchInterval);
|
clearTimeout(timeout);
|
||||||
clearInterval(trackStoppedBatchInterval);
|
|
||||||
callObject.off('track-started', handleTrackStarted);
|
|
||||||
callObject.off('track-stopped', handleTrackStopped);
|
|
||||||
callObject.off('participant-joined', handleParticipantJoined);
|
|
||||||
callObject.off('participant-left', handleParticipantLeft);
|
|
||||||
};
|
};
|
||||||
}, [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 =
|
const hasAudioChanged =
|
||||||
// State changed
|
// State changed
|
||||||
participant.tracks.audio?.state !==
|
participant.tracks.audio.state !==
|
||||||
state.audioTracks?.[participant.user_id]?.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
|
// Off/blocked reason changed
|
||||||
!deepEqual(
|
!deepEqual(
|
||||||
{
|
{
|
||||||
|
|
@ -237,14 +255,14 @@ export const TracksProvider = ({ children }) => {
|
||||||
...(participant.tracks.audio?.off ?? {}),
|
...(participant.tracks.audio?.off ?? {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...(state.audioTracks?.[participant.user_id]?.blocked ?? {}),
|
...(state.audioTracks?.[participant.user_id].blocked ?? {}),
|
||||||
...(state.audioTracks?.[participant.user_id]?.off ?? {}),
|
...(state.audioTracks?.[participant.user_id].off ?? {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const hasVideoChanged =
|
const hasVideoChanged =
|
||||||
// State changed
|
// State changed
|
||||||
participant.tracks.video?.state !==
|
participant.tracks.video?.state !==
|
||||||
state.videoTracks?.[participant.user_id]?.state ||
|
state.videoTracks?.[participant.user_id]?.state ||
|
||||||
// Off/blocked reason changed
|
// Off/blocked reason changed
|
||||||
!deepEqual(
|
!deepEqual(
|
||||||
{
|
{
|
||||||
|
|
@ -260,7 +278,7 @@ export const TracksProvider = ({ children }) => {
|
||||||
if (hasAudioChanged) {
|
if (hasAudioChanged) {
|
||||||
// Update audio track state
|
// Update audio track state
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TRACK_AUDIO_UPDATED,
|
type: 'UPDATE_AUDIO_TRACK',
|
||||||
participant,
|
participant,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -268,27 +286,92 @@ export const TracksProvider = ({ children }) => {
|
||||||
if (hasVideoChanged) {
|
if (hasVideoChanged) {
|
||||||
// Update video track state
|
// Update video track state
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TRACK_VIDEO_UPDATED,
|
type: 'UPDATE_VIDEO_TRACK',
|
||||||
participant,
|
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 () => {
|
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 (
|
return (
|
||||||
<TracksContext.Provider
|
<TracksContext.Provider
|
||||||
value={{
|
value={{
|
||||||
audioTracks: state.audioTracks,
|
audioTracks: state.audioTracks,
|
||||||
videoTracks: state.videoTracks,
|
videoTracks: state.videoTracks,
|
||||||
|
maxCamSubscriptions,
|
||||||
subscribeToCam,
|
subscribeToCam,
|
||||||
updateCamSubscriptions,
|
updateCamSubscriptions,
|
||||||
remoteParticipantIds,
|
|
||||||
recentSpeakerIds,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -296,8 +379,4 @@ export const TracksProvider = ({ children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TracksProvider.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTracks = () => useContext(TracksContext);
|
export const useTracks = () => useContext(TracksContext);
|
||||||
|
|
@ -21,6 +21,7 @@ export const UIStateProvider = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [pinnedId, setPinnedId] = useState(null);
|
const [pinnedId, setPinnedId] = useState(null);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [preferredViewMode, setPreferredViewMode] = useState(VIEW_MODE_SPEAKER);
|
const [preferredViewMode, setPreferredViewMode] = useState(VIEW_MODE_SPEAKER);
|
||||||
const [viewMode, setViewMode] = useState(preferredViewMode);
|
const [viewMode, setViewMode] = useState(preferredViewMode);
|
||||||
const [isShowingScreenshare, setIsShowingScreenshare] = useState(false);
|
const [isShowingScreenshare, setIsShowingScreenshare] = useState(false);
|
||||||
|
|
@ -28,6 +29,21 @@ export const UIStateProvider = ({
|
||||||
const [showAside, setShowAside] = useState();
|
const [showAside, setShowAside] = useState();
|
||||||
const [activeModals, setActiveModals] = useState({});
|
const [activeModals, setActiveModals] = useState({});
|
||||||
const [customCapsule, setCustomCapsule] = 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) => {
|
const openModal = useCallback((modalName) => {
|
||||||
setActiveModals((prevState) => ({
|
setActiveModals((prevState) => ({
|
||||||
|
|
@ -87,6 +103,10 @@ export const UIStateProvider = ({
|
||||||
setShowParticipantsBar,
|
setShowParticipantsBar,
|
||||||
customCapsule,
|
customCapsule,
|
||||||
setCustomCapsule,
|
setCustomCapsule,
|
||||||
|
showAutoplayFailedModal,
|
||||||
|
setShowAutoplayFailedModal,
|
||||||
|
isMobile,
|
||||||
|
setIsMobile,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -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 fasteq from 'fast-deep-equal';
|
||||||
import {
|
|
||||||
DEVICE_STATE_OFF,
|
import { MAX_RECENT_SPEAKER_COUNT } from './TracksProvider';
|
||||||
DEVICE_STATE_BLOCKED,
|
|
||||||
DEVICE_STATE_LOADING,
|
|
||||||
} from './useDevices';
|
|
||||||
|
|
||||||
const initialParticipantsState = {
|
const initialParticipantsState = {
|
||||||
lastPendingUnknownActiveSpeaker: null,
|
lastPendingUnknownActiveSpeaker: null,
|
||||||
|
|
@ -22,7 +9,6 @@ const initialParticipantsState = {
|
||||||
camMutedByHost: false,
|
camMutedByHost: false,
|
||||||
hasNameSet: false,
|
hasNameSet: false,
|
||||||
id: 'local',
|
id: 'local',
|
||||||
user_id: '',
|
|
||||||
isActiveSpeaker: false,
|
isActiveSpeaker: false,
|
||||||
isCamMuted: false,
|
isCamMuted: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|
@ -34,120 +20,20 @@ const initialParticipantsState = {
|
||||||
lastActiveDate: null,
|
lastActiveDate: null,
|
||||||
micMutedByHost: false,
|
micMutedByHost: false,
|
||||||
name: '',
|
name: '',
|
||||||
|
sessionId: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
screens: [],
|
screens: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Derived data ---
|
// --- Reducer and helpers --
|
||||||
|
|
||||||
function getId(participant) {
|
function participantsReducer(
|
||||||
return participant.local ? 'local' : participant.user_id;
|
prevState,
|
||||||
}
|
action
|
||||||
|
) {
|
||||||
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) {
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ACTIVE_SPEAKER: {
|
case 'ACTIVE_SPEAKER': {
|
||||||
const { participants, ...state } = prevState;
|
const { participants, ...state } = prevState;
|
||||||
if (!action.id)
|
if (!action.id)
|
||||||
return {
|
return {
|
||||||
|
|
@ -161,9 +47,9 @@ function participantsReducer(prevState, action) {
|
||||||
lastPendingUnknownActiveSpeaker: isParticipantKnown
|
lastPendingUnknownActiveSpeaker: isParticipantKnown
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
date,
|
date,
|
||||||
id: action.id,
|
id: action.id,
|
||||||
},
|
},
|
||||||
participants: participants.map((p) => ({
|
participants: participants.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
isActiveSpeaker: p.id === action.id,
|
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 item = getNewParticipant(action.participant);
|
||||||
|
|
||||||
const participants = [...prevState.participants];
|
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
|
// Participant is sharing screen
|
||||||
if (action.participant.screen) {
|
if (action.participant.screen) {
|
||||||
screens.push(getScreenItem(action.participant));
|
screens.push(getScreenItem(action.participant));
|
||||||
|
|
@ -211,7 +117,7 @@ function participantsReducer(prevState, action) {
|
||||||
screens,
|
screens,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case PARTICIPANT_UPDATED: {
|
case 'PARTICIPANT_UPDATED': {
|
||||||
const item = getUpdatedParticipant(
|
const item = getUpdatedParticipant(
|
||||||
action.participant,
|
action.participant,
|
||||||
prevState.participants
|
prevState.participants
|
||||||
|
|
@ -221,6 +127,10 @@ function participantsReducer(prevState, action) {
|
||||||
|
|
||||||
const participants = [...prevState.participants];
|
const participants = [...prevState.participants];
|
||||||
const idx = participants.findIndex((p) => p.id === id);
|
const idx = participants.findIndex((p) => p.id === id);
|
||||||
|
if (!item.isMicMuted && participants[idx].isMicMuted) {
|
||||||
|
// Participant unmuted mic
|
||||||
|
item.lastActiveDate = new Date();
|
||||||
|
}
|
||||||
participants[idx] = item;
|
participants[idx] = item;
|
||||||
|
|
||||||
const screens = [...prevState.screens];
|
const screens = [...prevState.screens];
|
||||||
|
|
@ -249,7 +159,7 @@ function participantsReducer(prevState, action) {
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
case PARTICIPANT_LEFT: {
|
case 'PARTICIPANT_LEFT': {
|
||||||
const id = getId(action.participant);
|
const id = getId(action.participant);
|
||||||
const screenId = getScreenId(id);
|
const screenId = getScreenId(id);
|
||||||
|
|
||||||
|
|
@ -259,7 +169,7 @@ function participantsReducer(prevState, action) {
|
||||||
screens: [...prevState.screens].filter((s) => s.id !== screenId),
|
screens: [...prevState.screens].filter((s) => s.id !== screenId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case SWAP_POSITION: {
|
case 'SWAP_POSITION': {
|
||||||
const participants = [...prevState.participants];
|
const participants = [...prevState.participants];
|
||||||
if (!action.id1 || !action.id2) return prevState;
|
if (!action.id1 || !action.id2) return prevState;
|
||||||
const idx1 = participants.findIndex((p) => p.id === action.id1);
|
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 {
|
export {
|
||||||
ACTIVE_SPEAKER,
|
|
||||||
getId,
|
getId,
|
||||||
getScreenId,
|
getScreenId,
|
||||||
|
initialParticipantsState,
|
||||||
isLocalId,
|
isLocalId,
|
||||||
isScreenId,
|
isScreenId,
|
||||||
participantsReducer,
|
participantsReducer,
|
||||||
initialParticipantsState,
|
|
||||||
PARTICIPANT_JOINED,
|
|
||||||
PARTICIPANT_LEFT,
|
|
||||||
PARTICIPANT_UPDATED,
|
|
||||||
SWAP_POSITION,
|
|
||||||
};
|
};
|
||||||
|
|
@ -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';
|
import { getId, getScreenId } from './participantsState';
|
||||||
|
|
||||||
export const initialTracksState = {
|
const initialTracksState = {
|
||||||
audioTracks: {},
|
audioTracks: {},
|
||||||
videoTracks: {},
|
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 --
|
// --- Reducer and helpers --
|
||||||
|
|
||||||
export function tracksReducer(prevState, action) {
|
function tracksReducer(
|
||||||
|
prevState,
|
||||||
|
action
|
||||||
|
) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case TRACK_STARTED: {
|
case 'TRACK_STARTED': {
|
||||||
const id = getId(action.participant);
|
const id = getId(action.participant);
|
||||||
const screenId = getScreenId(id);
|
const screenId = getScreenId(id);
|
||||||
|
|
||||||
|
|
@ -63,17 +50,15 @@ export function tracksReducer(prevState, action) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'TRACKS_STOPPED': {
|
||||||
case TRACK_STOPPED: {
|
|
||||||
const { audioTracks, videoTracks } = prevState;
|
const { audioTracks, videoTracks } = prevState;
|
||||||
|
|
||||||
const newAudioTracks = { ...audioTracks };
|
const newAudioTracks = { ...audioTracks };
|
||||||
const newVideoTracks = { ...videoTracks };
|
const newVideoTracks = { ...videoTracks };
|
||||||
|
|
||||||
action.items.forEach(([participant, track]) => {
|
for (const [participant, track] of action.items) {
|
||||||
const id = participant ? getId(participant) : null;
|
const id = participant ? getId(participant) : null;
|
||||||
const screenId = participant ? getScreenId(id) : null;
|
const screenId = participant ? getScreenId(id) : null;
|
||||||
|
|
||||||
if (track.kind === 'audio') {
|
if (track.kind === 'audio') {
|
||||||
if (!participant?.local) {
|
if (!participant?.local) {
|
||||||
// Ignore local audio from mic and screen share
|
// Ignore local audio from mic and screen share
|
||||||
|
|
@ -88,16 +73,16 @@ export function tracksReducer(prevState, action) {
|
||||||
newVideoTracks[screenId] = participant.tracks.screenVideo;
|
newVideoTracks[screenId] = participant.tracks.screenVideo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioTracks: newAudioTracks,
|
audioTracks: newAudioTracks,
|
||||||
videoTracks: newVideoTracks,
|
videoTracks: newVideoTracks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'UPDATE_AUDIO_TRACK': {
|
||||||
case TRACK_AUDIO_UPDATED: {
|
|
||||||
const id = getId(action.participant);
|
const id = getId(action.participant);
|
||||||
|
const screenId = getScreenId(id);
|
||||||
if (action.participant?.local) {
|
if (action.participant?.local) {
|
||||||
// Ignore local audio from mic and screen share
|
// Ignore local audio from mic and screen share
|
||||||
return prevState;
|
return prevState;
|
||||||
|
|
@ -105,14 +90,14 @@ export function tracksReducer(prevState, action) {
|
||||||
const newAudioTracks = {
|
const newAudioTracks = {
|
||||||
...prevState.audioTracks,
|
...prevState.audioTracks,
|
||||||
[id]: action.participant.tracks.audio,
|
[id]: action.participant.tracks.audio,
|
||||||
|
[screenId]: action.participant.tracks.screenAudio,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...prevState,
|
...prevState,
|
||||||
audioTracks: newAudioTracks,
|
audioTracks: newAudioTracks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'UPDATE_VIDEO_TRACK': {
|
||||||
case TRACK_VIDEO_UPDATED: {
|
|
||||||
const id = getId(action.participant);
|
const id = getId(action.participant);
|
||||||
const newVideoTracks = {
|
const newVideoTracks = {
|
||||||
...prevState.videoTracks,
|
...prevState.videoTracks,
|
||||||
|
|
@ -123,8 +108,7 @@ export function tracksReducer(prevState, action) {
|
||||||
videoTracks: newVideoTracks,
|
videoTracks: newVideoTracks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'REMOVE_TRACKS': {
|
||||||
case REMOVE_TRACKS: {
|
|
||||||
const { audioTracks, videoTracks } = prevState;
|
const { audioTracks, videoTracks } = prevState;
|
||||||
const id = getId(action.participant);
|
const id = getId(action.participant);
|
||||||
const screenId = getScreenId(id);
|
const screenId = getScreenId(id);
|
||||||
|
|
@ -139,8 +123,9 @@ export function tracksReducer(prevState, action) {
|
||||||
videoTracks,
|
videoTracks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { initialTracksState, tracksReducer };
|
||||||
|
|
@ -29,6 +29,7 @@ export const CALL_STATE_REDIRECTING = 'redirecting';
|
||||||
export const CALL_STATE_NOT_FOUND = 'not-found';
|
export const CALL_STATE_NOT_FOUND = 'not-found';
|
||||||
export const CALL_STATE_NOT_ALLOWED = 'not-allowed';
|
export const CALL_STATE_NOT_ALLOWED = 'not-allowed';
|
||||||
export const CALL_STATE_AWAITING_ARGS = 'awaiting-args';
|
export const CALL_STATE_AWAITING_ARGS = 'awaiting-args';
|
||||||
|
export const CALL_STATE_NOT_SECURE = 'not-secure';
|
||||||
|
|
||||||
export const useCallMachine = ({
|
export const useCallMachine = ({
|
||||||
domain,
|
domain,
|
||||||
|
|
@ -78,10 +79,17 @@ export const useCallMachine = ({
|
||||||
const join = useCallback(
|
const join = useCallback(
|
||||||
async (callObject) => {
|
async (callObject) => {
|
||||||
setState(CALL_STATE_JOINING);
|
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 });
|
await callObject.join({ subscribeToTracksAutomatically, token, url });
|
||||||
setState(CALL_STATE_JOINED);
|
setState(CALL_STATE_JOINED);
|
||||||
},
|
},
|
||||||
[token, subscribeToTracksAutomatically, url]
|
[room, token, subscribeToTracksAutomatically, url]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -182,6 +190,15 @@ export const useCallMachine = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (daily || !url || state !== CALL_STATE_READY) return;
|
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');
|
console.log('🚀 Creating call object');
|
||||||
|
|
||||||
const co = DailyIframe.createCallObject({
|
const co = DailyIframe.createCallObject({
|
||||||
|
|
@ -200,7 +217,7 @@ export const useCallMachine = ({
|
||||||
* Listen for changes in the participant's access state
|
* Listen for changes in the participant's access state
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!daily) return false;
|
if (!daily) return;
|
||||||
|
|
||||||
daily.on('access-state-updated', handleAccessStateUpdated);
|
daily.on('access-state-updated', handleAccessStateUpdated);
|
||||||
return () => daily.off('access-state-updated', handleAccessStateUpdated);
|
return () => daily.off('access-state-updated', handleAccessStateUpdated);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ export const DEVICE_STATE_SENDABLE = 'sendable';
|
||||||
|
|
||||||
export const useDevices = (callObject) => {
|
export const useDevices = (callObject) => {
|
||||||
const [deviceState, setDeviceState] = useState(DEVICE_STATE_LOADING);
|
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 [cams, setCams] = useState([]);
|
||||||
const [mics, setMics] = useState([]);
|
const [mics, setMics] = useState([]);
|
||||||
|
|
@ -38,6 +40,10 @@ export const useDevices = (callObject) => {
|
||||||
|
|
||||||
const { camera, mic, speaker } = await callObject.getInputDevices();
|
const { camera, mic, speaker } = await callObject.getInputDevices();
|
||||||
|
|
||||||
|
setCurrentCam(camera ?? null);
|
||||||
|
setCurrentMic(mic ?? null);
|
||||||
|
setCurrentSpeaker(speaker ?? null);
|
||||||
|
|
||||||
const [defaultCam, ...videoDevices] = devices.filter(
|
const [defaultCam, ...videoDevices] = devices.filter(
|
||||||
(d) => d.kind === 'videoinput' && d.deviceId !== ''
|
(d) => d.kind === 'videoinput' && d.deviceId !== ''
|
||||||
);
|
);
|
||||||
|
|
@ -66,12 +72,6 @@ export const useDevices = (callObject) => {
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
setCurrentDevices({
|
|
||||||
camera,
|
|
||||||
mic,
|
|
||||||
speaker,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Current cam: ${camera.label}`);
|
console.log(`Current cam: ${camera.label}`);
|
||||||
console.log(`Current mic: ${mic.label}`);
|
console.log(`Current mic: ${mic.label}`);
|
||||||
console.log(`Current speakers: ${speaker.label}`);
|
console.log(`Current speakers: ${speaker.label}`);
|
||||||
|
|
@ -125,31 +125,26 @@ export const useDevices = (callObject) => {
|
||||||
|
|
||||||
const handleParticipantUpdated = useCallback(
|
const handleParticipantUpdated = useCallback(
|
||||||
({ participant }) => {
|
({ participant }) => {
|
||||||
if (!callObject || !participant.local) return;
|
if (!callObject || deviceState === 'not-supported' || !participant.local) return;
|
||||||
|
|
||||||
setDeviceState((prevState) => {
|
switch (participant?.tracks.video.state) {
|
||||||
if (prevState === DEVICE_STATE_NOT_SUPPORTED) return prevState;
|
case DEVICE_STATE_BLOCKED:
|
||||||
switch (participant?.tracks.video.state) {
|
setDeviceState(DEVICE_STATE_ERROR);
|
||||||
case DEVICE_STATE_BLOCKED:
|
break;
|
||||||
updateDeviceErrors();
|
case DEVICE_STATE_OFF:
|
||||||
return DEVICE_STATE_ERROR;
|
case DEVICE_STATE_PLAYABLE:
|
||||||
case DEVICE_STATE_OFF:
|
updateDeviceState();
|
||||||
case DEVICE_STATE_PLAYABLE:
|
setDeviceState(DEVICE_STATE_GRANTED);
|
||||||
if (prevState === DEVICE_STATE_GRANTED) {
|
break;
|
||||||
return prevState;
|
}
|
||||||
}
|
|
||||||
updateDeviceState();
|
updateDeviceErrors();
|
||||||
return DEVICE_STATE_GRANTED;
|
|
||||||
default:
|
|
||||||
return prevState;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[callObject, updateDeviceState, updateDeviceErrors]
|
[callObject, deviceState, updateDeviceErrors, updateDeviceState]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callObject) return false;
|
if (!callObject) return;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
If the user is slow to allow access, we'll update the device state
|
If the user is slow to allow access, we'll update the device state
|
||||||
|
|
@ -169,6 +164,7 @@ export const useDevices = (callObject) => {
|
||||||
updateDeviceState();
|
updateDeviceState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateDeviceState();
|
||||||
callObject.on('joining-meeting', handleJoiningMeeting);
|
callObject.on('joining-meeting', handleJoiningMeeting);
|
||||||
callObject.on('joined-meeting', handleJoinedMeeting);
|
callObject.on('joined-meeting', handleJoinedMeeting);
|
||||||
callObject.on('participant-updated', handleParticipantUpdated);
|
callObject.on('participant-updated', handleParticipantUpdated);
|
||||||
|
|
@ -180,74 +176,8 @@ export const useDevices = (callObject) => {
|
||||||
};
|
};
|
||||||
}, [callObject, handleParticipantUpdated, updateDeviceState]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!callObject) return false;
|
if (!callObject) return;
|
||||||
|
|
||||||
console.log('💻 Device provider events bound');
|
console.log('💻 Device provider events bound');
|
||||||
|
|
||||||
|
|
@ -313,16 +243,19 @@ export const useDevices = (callObject) => {
|
||||||
}, [callObject, updateDeviceErrors]);
|
}, [callObject, updateDeviceErrors]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cams,
|
|
||||||
mics,
|
|
||||||
speakers,
|
|
||||||
camError,
|
camError,
|
||||||
micError,
|
cams,
|
||||||
currentDevices,
|
currentCam,
|
||||||
|
currentMic,
|
||||||
|
currentSpeaker,
|
||||||
deviceState,
|
deviceState,
|
||||||
setCamDevice,
|
micError,
|
||||||
setMicDevice,
|
mics,
|
||||||
setSpeakersDevice,
|
refreshDevices: updateDeviceState,
|
||||||
|
setCurrentCam,
|
||||||
|
setCurrentMic,
|
||||||
|
setCurrentSpeaker,
|
||||||
|
speakers,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import { useParticipants } from '../contexts/ParticipantsProvider';
|
||||||
* (= the current one and only actively speaking person)
|
* (= the current one and only actively speaking person)
|
||||||
*/
|
*/
|
||||||
export const useActiveSpeaker = () => {
|
export const useActiveSpeaker = () => {
|
||||||
const { showLocalVideo } = useCallState();
|
const { broadcastRole, showLocalVideo } = useCallState();
|
||||||
const { activeParticipant, localParticipant, participantCount } =
|
const { activeParticipant, localParticipant, participantCount } =
|
||||||
useParticipants();
|
useParticipants();
|
||||||
|
|
||||||
// we don't show active speaker indicators EVER in a 1:1 call or when the user is alone in-call
|
// 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) {
|
if (!activeParticipant?.isMicMuted) {
|
||||||
return activeParticipant?.id;
|
return activeParticipant?.id;
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,54 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
|
||||||
export const useAudioLevel = (sessionId) => {
|
export const useAudioLevel = (stream) => {
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [micVolume, setMicVolume] = useState(0);
|
||||||
|
const { assetPrefix } = getConfig().publicRuntimeConfig;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) {
|
if (!stream) {
|
||||||
return false;
|
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 {
|
try {
|
||||||
if (!(window.rtcpeers && window.rtcpeers.sfu)) {
|
await audioContext.audioWorklet.addModule(
|
||||||
return;
|
`${assetPrefix}/audiolevel-processor.js`
|
||||||
}
|
);
|
||||||
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);
|
|
||||||
|
|
||||||
return () => clearInterval(i);
|
node = new AudioWorkletNode(audioContext, 'audiolevel');
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
return audioLevel;
|
node.port.onmessage = (event) => {
|
||||||
|
let volume = 0;
|
||||||
|
if (event.data.volume) volume = event.data.volume;
|
||||||
|
if (!node) return;
|
||||||
|
setMicVolume(volume);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaStreamSource.connect(node).connect(audioContext.destination);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
startProcessing();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
node?.disconnect();
|
||||||
|
node = null;
|
||||||
|
mediaStreamSource?.disconnect();
|
||||||
|
audioContext?.close();
|
||||||
|
};
|
||||||
|
}, [assetPrefix, stream]);
|
||||||
|
|
||||||
|
return micVolume;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useAudioLevel;
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { useDeepCompareMemo } from 'use-deep-compare';
|
||||||
|
|
||||||
import { useTracks } from '../contexts/TracksProvider';
|
import { useTracks } from '../contexts/TracksProvider';
|
||||||
|
|
||||||
export const useAudioTrack = (participant) => {
|
export const useAudioTrack = (id) => {
|
||||||
const { audioTracks } = useTracks();
|
const { audioTracks } = useTracks();
|
||||||
|
|
||||||
return useDeepCompareMemo(() => {
|
return useDeepCompareMemo(() => {
|
||||||
const audioTrack = audioTracks?.[participant?.id];
|
const audioTrack = audioTracks?.[id];
|
||||||
// @ts-ignore
|
|
||||||
return audioTrack?.persistentTrack;
|
return audioTrack?.persistentTrack;
|
||||||
}, [participant?.id, audioTracks]);
|
}, [id, audioTracks]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useAudioTrack;
|
export default useAudioTrack;
|
||||||
|
|
@ -15,12 +15,10 @@ export const useCamSubscriptions = (
|
||||||
const { updateCamSubscriptions } = useTracks();
|
const { updateCamSubscriptions } = useTracks();
|
||||||
|
|
||||||
useDeepCompareEffect(() => {
|
useDeepCompareEffect(() => {
|
||||||
if (!subscribedIds || !stagedIds) return false;
|
if (!subscribedIds || !stagedIds) return;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
updateCamSubscriptions(subscribedIds, stagedIds);
|
updateCamSubscriptions(subscribedIds, stagedIds);
|
||||||
}, throttle);
|
}, throttle);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [subscribedIds, stagedIds, throttle, updateCamSubscriptions]);
|
}, [subscribedIds, stagedIds, throttle, updateCamSubscriptions]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useCamSubscriptions;
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,42 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { debounce } from 'debounce';
|
|
||||||
import { useCallState } from '../contexts/CallProvider';
|
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 = () => {
|
export const useJoinSound = () => {
|
||||||
const { callObject } = useCallState();
|
const { callObject: daily } = useCallState();
|
||||||
const { load, play } = useSound('assets/join.mp3');
|
const { joinSound } = useSoundLoader();
|
||||||
|
const [playJoinSound, setPlayJoinSound] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
if (!daily) return;
|
||||||
}, [load]);
|
/**
|
||||||
|
* We don't want to immediately play a joined sound, when the user joins the meeting:
|
||||||
const debouncedPlay = useMemo(() => debounce(() => play(), 200), [play]);
|
* Upon joining all other participants, that were already in-call, will emit a
|
||||||
|
* participant-joined event.
|
||||||
useEffect(() => {
|
* In waiting 2 seconds we make sure, that the sound is only played when the user
|
||||||
if (!callObject) return false;
|
* is **really** the first participant.
|
||||||
|
*/
|
||||||
const handleParticipantJoined = () => {
|
|
||||||
debouncedPlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
callObject.on('participant-joined', handleParticipantJoined);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleParticipantJoined();
|
setPlayJoinSound(true);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}, [daily]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
callObject.off('participant-joined', handleParticipantJoined);
|
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]);
|
||||||
|
};
|
||||||
|
|
@ -1,51 +1,55 @@
|
||||||
/* global rtcpeers */
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useCallback, useEffect, 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_HIGH_BITRATE_CAP = 980;
|
||||||
const STANDARD_LOW_BITRATE_CAP = 300;
|
const STANDARD_LOW_BITRATE_CAP = 300;
|
||||||
|
|
||||||
export const useNetworkState = (
|
export const useNetworkState = (
|
||||||
callObject = null,
|
co = null,
|
||||||
quality = VIDEO_QUALITY_HIGH
|
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(
|
const setQuality = useCallback(
|
||||||
(q) => {
|
async (q) => {
|
||||||
if (!callObject || typeof rtcpeers === 'undefined') return;
|
if (!callObject) return;
|
||||||
|
|
||||||
const peers = Object.keys(callObject.participants()).length - 1;
|
const peers = Object.keys(callObject.participants()).length - 1;
|
||||||
const isSFU = rtcpeers?.currentlyPreferred?.typeName?.() === 'sfu';
|
const isSFU = (await callObject.getNetworkTopology()).topology === 'sfu';
|
||||||
|
|
||||||
const lowKbs = isSFU
|
const lowKbs = isSFU
|
||||||
? STANDARD_LOW_BITRATE_CAP
|
? 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) {
|
switch (q) {
|
||||||
case VIDEO_QUALITY_HIGH:
|
case 'auto':
|
||||||
callObject.setBandwidth({ kbs: STANDARD_HIGH_BITRATE_CAP });
|
case 'high':
|
||||||
|
if (lastSetKBS.current === highKbs) break;
|
||||||
|
callObject.setBandwidth({
|
||||||
|
kbs: highKbs,
|
||||||
|
});
|
||||||
|
lastSetKBS.current = highKbs;
|
||||||
break;
|
break;
|
||||||
case VIDEO_QUALITY_LOW:
|
case 'low':
|
||||||
|
if (lastSetKBS.current === lowKbs) break;
|
||||||
callObject.setBandwidth({
|
callObject.setBandwidth({
|
||||||
kbs: lowKbs,
|
kbs: lowKbs,
|
||||||
});
|
});
|
||||||
|
lastSetKBS.current = lowKbs;
|
||||||
break;
|
break;
|
||||||
case VIDEO_QUALITY_BANDWIDTH_SAVER:
|
case 'bandwidth-saver':
|
||||||
callObject.setLocalVideo(false);
|
callObject.setLocalVideo(false);
|
||||||
|
if (lastSetKBS.current === lowKbs) break;
|
||||||
callObject.setBandwidth({
|
callObject.setBandwidth({
|
||||||
kbs: lowKbs,
|
kbs: lowKbs,
|
||||||
});
|
});
|
||||||
break;
|
lastSetKBS.current = lowKbs;
|
||||||
default:
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -57,43 +61,50 @@ export const useNetworkState = (
|
||||||
if (ev.threshold === threshold) return;
|
if (ev.threshold === threshold) return;
|
||||||
|
|
||||||
switch (ev.threshold) {
|
switch (ev.threshold) {
|
||||||
case NETWORK_STATE_VERY_LOW:
|
case 'very-low':
|
||||||
setQuality(VIDEO_QUALITY_BANDWIDTH_SAVER);
|
setQuality('bandwidth-saver');
|
||||||
setThreshold(NETWORK_STATE_VERY_LOW);
|
setThreshold('very-low');
|
||||||
break;
|
break;
|
||||||
case NETWORK_STATE_LOW:
|
case 'low':
|
||||||
|
setQuality(quality === 'bandwidth-saver' ? quality : 'low');
|
||||||
|
setThreshold('low');
|
||||||
|
break;
|
||||||
|
case 'good':
|
||||||
setQuality(
|
setQuality(
|
||||||
quality === VIDEO_QUALITY_BANDWIDTH_SAVER
|
['bandwidth-saver', 'low'].includes(quality) ? quality : 'high'
|
||||||
? quality
|
|
||||||
: NETWORK_STATE_LOW
|
|
||||||
);
|
);
|
||||||
setThreshold(NETWORK_STATE_LOW);
|
setThreshold('good');
|
||||||
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:
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setQuality, threshold, quality]
|
[quality, setQuality, threshold]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callObject) return false;
|
if (!callObject) return;
|
||||||
callObject.on('network-quality-change', handleNetworkQualityChange);
|
callObject.on('network-quality-change', handleNetworkQualityChange);
|
||||||
return () =>
|
return () => {
|
||||||
callObject.off('network-quality-change', handleNetworkQualityChange);
|
callObject.off('network-quality-change', handleNetworkQualityChange);
|
||||||
|
};
|
||||||
}, [callObject, handleNetworkQualityChange]);
|
}, [callObject, handleNetworkQualityChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!callObject) return;
|
||||||
setQuality(quality);
|
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;
|
return threshold;
|
||||||
};
|
};
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export const useSound = (src) => {
|
const defaultNotMuted = () => false;
|
||||||
|
|
||||||
|
export const useSound = (src, isMuted = defaultNotMuted) => {
|
||||||
const audio = useRef(null);
|
const audio = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -22,17 +24,15 @@ export const useSound = (src) => {
|
||||||
audio.current.load();
|
audio.current.load();
|
||||||
}, [audio]);
|
}, [audio]);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(async () => {
|
||||||
if (!audio.current) return;
|
if (!audio.current || isMuted()) return;
|
||||||
try {
|
try {
|
||||||
audio.current.currentTime = 0;
|
audio.current.currentTime = 0;
|
||||||
audio.current.play();
|
await audio.current.play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}, [audio]);
|
}, [audio, isMuted]);
|
||||||
|
|
||||||
return { load, play };
|
return { load, play };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useSound;
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
import { useDeepCompareMemo } from 'use-deep-compare';
|
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 { 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(() => {
|
return useDeepCompareMemo(() => {
|
||||||
const videoTrack = videoTracks?.[participant?.id];
|
|
||||||
if (
|
if (
|
||||||
videoTrack?.state === DEVICE_STATE_OFF ||
|
videoTrack?.state === 'off' ||
|
||||||
videoTrack?.state === DEVICE_STATE_BLOCKED ||
|
videoTrack?.state === 'blocked' ||
|
||||||
(!videoTrack?.subscribed &&
|
(!videoTrack?.subscribed && !isLocalId(id) && !isScreenId(id))
|
||||||
participant?.id !== 'local' &&
|
|
||||||
!participant.isScreenshare)
|
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
return videoTrack?.persistentTrack;
|
return videoTrack?.persistentTrack;
|
||||||
}, [participant?.id, videoTracks]);
|
}, [id, videoTrack]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useVideoTrack;
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 };
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
export const sortByKey = (a, b, key, caseSensitive = true) => {
|
export const sortByKey = (key, caseSensitive = true) =>
|
||||||
|
(a, b) => {
|
||||||
const aKey =
|
const aKey =
|
||||||
!caseSensitive && typeof a[key] === 'string'
|
!caseSensitive && typeof a[key] === 'string'
|
||||||
? a[key]?.toLowerCase()
|
? String(a[key])?.toLowerCase()
|
||||||
: a[key];
|
: a[key];
|
||||||
const bKey =
|
const bKey =
|
||||||
!caseSensitive && typeof b[key] === 'string'
|
!caseSensitive && typeof b[key] === 'string'
|
||||||
? b[key]?.toLowerCase()
|
? String(b[key])?.toLowerCase()
|
||||||
: b[key];
|
: b[key];
|
||||||
if (aKey > bKey) return 1;
|
if (aKey > bKey) return 1;
|
||||||
if (aKey < bKey) return -1;
|
if (aKey < bKey) return -1;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default sortByKey;
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import Button from '@custom/shared/components/Button';
|
||||||
import { TextInput } from '@custom/shared/components/Input';
|
import { TextInput } from '@custom/shared/components/Input';
|
||||||
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
|
import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider';
|
||||||
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
|
import { useUIState } from '@custom/shared/contexts/UIStateProvider';
|
||||||
|
import { useMessageSound } from '@custom/text-chat/hooks/useMessageSound';
|
||||||
import { useChat } from '../contexts/ChatProvider';
|
import { useChat } from '../contexts/ChatProvider';
|
||||||
import { useMessageSound } from '../hooks/useMessageSound';
|
|
||||||
|
|
||||||
export const CHAT_ASIDE = 'chat';
|
export const CHAT_ASIDE = 'chat';
|
||||||
|
|
||||||
|
|
|
||||||