initial commit
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"extends": ["airbnb", "airbnb/hooks", "prettier"],
|
||||
"parser": "babel-eslint",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": 0,
|
||||
"react/jsx-props-no-spreading": 0,
|
||||
"react/forbid-prop-types": 0,
|
||||
"react/require-default-props": 0,
|
||||
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
|
||||
"import/no-extraneous-dependencies": 0,
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"groups": ["builtin", "external", "parent", "sibling", "index"],
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "react",
|
||||
"group": "external",
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"pathGroupsExcludedImportTypes": ["react"],
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,6 @@
|
|||
{
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
help@daily.co. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# Contributing
|
||||
|
||||
Thank you for looking into contributing to`daily-demos`! We want these projects to help people experiment with Daily more quickly. We especially welcome any contributions that help us make existing demos easier to understand, improve demos' instructions and descriptions, and we're especially excited about any new demos that highlight unique ways to use the [Daily API](https://docs.daily.co/reference).
|
||||
|
||||
**Before contributing:**
|
||||
|
||||
- [Read our code of conduct](#read-our-code-of-conduct)
|
||||
|
||||
**How to contribute:**
|
||||
|
||||
- [Open or claim an issue](#open-or-claim-an-issue)
|
||||
- [Open a pull request](#open-a-pull-request)
|
||||
- [Contribute a new demo project](#contribute-a-new-demo-project)
|
||||
|
||||
## Before contributing
|
||||
|
||||
### Run prebuilt-ui locally
|
||||
|
||||
Please follow the instructions in `README.md`.
|
||||
|
||||
### Read our code of conduct
|
||||
|
||||
We use the [Contributor Covenant](https://www.contributor-covenant.org/) for our Code of Conduct. Before contributing, [please read it](CODE_OF_CONDUCT.md).
|
||||
|
||||
## How to contribute
|
||||
|
||||
### Open or claim an issue
|
||||
|
||||
#### Open an issue
|
||||
|
||||
Today we work off two main issue templates: _bug reports_ and _demo/feature requests_.
|
||||
|
||||
_Bug reports_
|
||||
|
||||
Before creating a new bug report, please do two things:
|
||||
|
||||
1. If you want to report a bug you experienced while on a Daily call, try out these [troubleshooting tips](https://help.daily.co/en/articles/2303117-top-troubleshooting-tips) to see if that takes care of the bug.
|
||||
2. If you're still seeing the error, check to see if somebody else has [already filed the issue](https://github.com/daily-co/daily-demos/issues) before creating a new one.
|
||||
|
||||
If you've done those two things and need to create an issue, we'll ask you to tell us:
|
||||
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- Steps to reproduce the error
|
||||
- Screenshots that illustrate where and what we should be looking for when we reproduce
|
||||
- System information, like your device, OS, and browser
|
||||
- Any additional context that you think could help us work through this
|
||||
|
||||
_Demo/feature requests_
|
||||
|
||||
We're always happy to hear about new ways you'd like to use Daily. If you'd like a demo that we don't have yet, we'll ask you to let us know:
|
||||
|
||||
- If the demo will help you solve a particular problem
|
||||
- Alternative solutions you've considered
|
||||
- Any additional context that might help us understand this ask
|
||||
|
||||
#### Claim an issue
|
||||
|
||||
All issues labeled `good-first-issue` are up for grabs. If you'd like to tackle an existing issue, feel free to assign yourself, and please leave a comment letting everyone know that you're on it.
|
||||
|
||||
### Open a pull request
|
||||
|
||||
- If it's been a minute or if you haven't yet cloned, forked, or branched a repository, GitHub has some [docs to help](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests).
|
||||
- When creating commit messages and pull request titles, please follow the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) standard.
|
||||
|
||||
### Contribute a new demo project
|
||||
|
||||
If you've built a project on Daily that you want to share with other developers, we'd be more than happy to help spread the word.
|
||||
|
||||
To add a new demo project:
|
||||
|
||||
Open a PR in [awesome-daily](#) and add a link to your project.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||

|
||||
|
||||
# [Daily](https://daily.co) | Examples
|
||||
|
||||
This repository uses [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) so it's required to have yarn [installed](https://classic.yarnpkg.com/en/docs/install).
|
||||
|
||||
## Getting started
|
||||
|
||||
Setup dependencies via `yarn install`.
|
||||
|
||||
Run an example via `yarn workspace @dailyjs/basic-call dev` (replacing `basic-call` with the name of the demo)
|
||||
|
||||
Please note: these demos are intended as educational resources for using the Daily platform as well as showcasing common usage patterns and best practices. That said, they are not intended to be used as production ready applications.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
## [Daily JS (Web)](./dailyjs/)
|
||||
|
||||
Examples that showcase the Daily CallObject using our Javascript library
|
||||
|
||||
## [Prebuilt UI](./dailyjs/prebuilt-ui)
|
||||
|
||||
Work in progress, examples coming soon
|
||||
|
||||
## [React Native (Mobile)](./dailyjs/react-native)
|
||||
|
||||
Work in progress, examples coming soon
|
||||
|
||||
---
|
||||
|
||||
## Contributions
|
||||
|
||||
We welcome all contributions that make it easier for developers to get to know Daily through these demos. If you'd like to open or claim an issue, add your own demo, or contribute in another way, please read CONTRIBUTING.md.
|
||||
|
||||
## Contact us
|
||||
|
||||
We're always happy to help developers building on Daily. Reach out to us any time at help@daily.co, or chat with us.
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# Daily JS Examples
|
||||
|
||||
Run an examples via `yarn workspace @dailyjs/basic-call dev` (replacing `basic-call` with the name of the demo) from the project root
|
||||
|
||||
Note: please ensure your rooms are setup to use [web sockets](https://docs.daily.co/reference#domain-configuration)
|
||||
|
||||
Note: examples are served using [nextjs](https://nextjs.org/)
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
```
|
||||
// run locally, from project root
|
||||
yarn
|
||||
yarn workspace @dailyjs/[example-to-run] dev
|
||||
```
|
||||
|
||||
We recommend starting with the [basic call](./basic-call) example, showcasing the common flow of a call Daily call, device management and error handling.
|
||||
|
||||
## Shared code
|
||||
|
||||
These examples re-use some common components, contexts, hooks and libraries. These can be found in the [shared](./shared) folder.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### [🤙 Basic call](./basic-call)
|
||||
|
||||
Simple call demo derived from our prebuilt UI codebase
|
||||
|
||||
---
|
||||
|
||||
## Coming soon
|
||||
|
||||
### [🔌 Device management](./device-management)
|
||||
|
||||
Manage media device, handle errors and set initial device mute state
|
||||
|
||||
### [🧑🤝🧑 1-to-1 calls](./examples/1-to-1-calls)
|
||||
|
||||
Simple p2p (single partcipant) call demo
|
||||
|
||||
### [🎚️ Audio gain monitor](./examples/audio-monitor)
|
||||
|
||||
Use AudioContext to create a audio gain meter
|
||||
|
||||
### [👱 Hair check](./examples/hair-check)
|
||||
|
||||
Check your audio and video before joining a call
|
||||
|
||||
### [🚪 Knock for access](./examples/knock-for-access)
|
||||
|
||||
Pre-authenticate and request access before joining a call. For owners, approve / deny requests
|
||||
|
||||
### [✋ Raise your hand](./examples/knock-for-access)
|
||||
|
||||
Use sendAppMessage to raise your hand to speak (stateless)
|
||||
|
||||
### [🔥 Reactions](./examples/reactions)
|
||||
|
||||
Use sendAppMessage to send emoji reactions during a call
|
||||
|
||||
### [💬 Text Chat](./examples/text-chat)
|
||||
|
||||
Create a fully featured text chat (stateless)
|
||||
|
||||
### [⏭️ Pagination](./examples/pagination)
|
||||
|
||||
Optimise call performance by paginating (and pausing / resuming tracks)
|
||||
|
||||
### [💯 Large calls](./examples/large-calls)
|
||||
|
||||
Optimisations for larger calls (100+ particpants)
|
||||
|
||||
### [🎙️ Live streaminng](./examples/live-streaming)
|
||||
|
||||
Example for how to broadcast and composite your calls via RTMP
|
||||
|
||||
### [⏺️ Cloud recording & composition](./examples/cloud-recording)
|
||||
|
||||
Record your call to Daily's cloud and composite the results via the REST API
|
||||
|
||||
### [📼 Local recording](./examples/local-recording)
|
||||
|
||||
Record calls to your local computer
|
||||
|
||||
### [🖥️ Screen sharing](./examples/audio-sharing)
|
||||
|
||||
Share your screen with participants during a call
|
||||
|
||||
### [🔊 Audio sharing](./examples/audio-sharing)
|
||||
|
||||
Share tab audio during a call and allow participants to manage audio mix
|
||||
|
||||
### [🎛️ Quality controls](./examples/quality-controls)
|
||||
|
||||
Optimise or enhance video and audio quality
|
||||
|
||||
## Stateful examples
|
||||
|
||||
### [User spotlighting](./examples/user-spotlighting-firebase)
|
||||
|
||||
Set a participant to be spotlighted during a call
|
||||
|
||||
### [Breakout rooms](./examples/breakout-rooms-firebase)
|
||||
|
||||
Allow participants to form or join breakout rooms
|
||||
|
||||
---
|
||||
|
||||
## Other
|
||||
|
||||
### [shared](./examples/shared)
|
||||
|
||||
Common components, context, hooks and libraries used across example projects
|
||||
|
|
@ -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,51 @@
|
|||
# Basic call
|
||||
|
||||

|
||||
|
||||
## 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 @dailyjs/basic-call dev
|
||||
```
|
||||
|
||||
## How does this example work?
|
||||
|
||||
This demo puts to work the following [shared libraries](../shared):
|
||||
|
||||
**[MediaProvider.js](../shared/contexts/MediaProvider.js)**
|
||||
Convenience context that provides an interface to media devices throughout app
|
||||
|
||||
**[useDevices.js](../shared/hooks/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/hooks/useCallMachine.js)**
|
||||
Abstraction hook that manages Daily call state and error handling
|
||||
|
||||
**[ParticipantProvider.js](../shared/contexts/ParticipantProvider.js)**
|
||||
Manages participant state and abstracts common selectors / derived data
|
||||
|
||||
## Deploy your own
|
||||
|
||||
Deploy the example using Vercel
|
||||
|
||||
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples%2Ftree%2Fmain%2Fdailyjs%2Fbasic-call&env=DAILY_DOMAIN,DAILY_API_KEY&project-name=dailyjs-basic-call&demo-title=Daily%20Basic%20Call%20Demo)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import Loader from '@dailyjs/shared/components/Loader';
|
||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import {
|
||||
CALL_STATE_ENDED,
|
||||
CALL_STATE_JOINED,
|
||||
CALL_STATE_JOINING,
|
||||
CALL_STATE_LOBBY,
|
||||
CALL_STATE_NOT_FOUND,
|
||||
CALL_STATE_NOT_BEFORE,
|
||||
CALL_STATE_READY,
|
||||
CALL_STATE_REDIRECTING,
|
||||
} from '@dailyjs/shared/contexts/useCallMachine';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import HairCheck from '../HairCheck';
|
||||
import MessageCard from '../MessageCard';
|
||||
import Room from '../Room';
|
||||
import { Modals } from './Modals';
|
||||
|
||||
export const App = () => {
|
||||
const { state, leave } = useCallState();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`%c🔀 App state changed: ${state}`, `color: gray;`);
|
||||
}, [state]);
|
||||
|
||||
const renderState = useCallback(() => {
|
||||
// Show loader when state is undefined or ready to join
|
||||
if (!state || [CALL_STATE_READY, CALL_STATE_JOINING].includes(state)) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Update the UI based on the state of our call
|
||||
switch (state) {
|
||||
case CALL_STATE_NOT_FOUND:
|
||||
router.replace('/not-found');
|
||||
return null;
|
||||
case CALL_STATE_NOT_BEFORE:
|
||||
return (
|
||||
<MessageCard error header="Cannot join before owner">
|
||||
This room has `nbf` set, meaning you cannot join the call before the
|
||||
owner
|
||||
</MessageCard>
|
||||
);
|
||||
case CALL_STATE_READY:
|
||||
case CALL_STATE_LOBBY:
|
||||
return <HairCheck />;
|
||||
case CALL_STATE_JOINED:
|
||||
return <Room onLeave={() => leave()} />;
|
||||
case CALL_STATE_REDIRECTING:
|
||||
case CALL_STATE_ENDED:
|
||||
// Note: you could set a manual redirect here but we'll show just an exit screen
|
||||
return (
|
||||
<MessageCard onBack={() => window.location.reload()}>
|
||||
You have left the call. We hope you had fun!
|
||||
</MessageCard>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageCard error header="An error occured">
|
||||
An unknown error has occured in the call loop. This should not happen!
|
||||
</MessageCard>
|
||||
);
|
||||
}, [leave, router, state]);
|
||||
|
||||
// Memoize children to avoid unnecassary renders from HOC
|
||||
return useMemo(
|
||||
() => (
|
||||
<div className="app">
|
||||
{renderState()}
|
||||
<Modals />
|
||||
<style jsx>{`
|
||||
color: white;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loader {
|
||||
margin: 0 auto;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
),
|
||||
[renderState]
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
||||
|
||||
export const Modals = () => (
|
||||
<>
|
||||
<DeviceSelectModal />
|
||||
</>
|
||||
);
|
||||
|
||||
export default Modals;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { App as default } from './App';
|
||||
export { Modals } from './Modals';
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useRef, useEffect } from 'react';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import useAudioTrack from '@dailyjs/shared/hooks/useAudioTrack';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const AudioItem = React.memo(({ participant }) => {
|
||||
const audioRef = useRef(null);
|
||||
const audioTrack = useAudioTrack(participant);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioTrack || !audioRef.current) return;
|
||||
|
||||
// sanity check to make sure this is an audio track
|
||||
if (audioTrack && audioTrack !== 'audio') return;
|
||||
|
||||
audioRef.current.srcObject = new MediaStream([audioTrack]);
|
||||
}, [audioTrack]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio autoPlay playsInline ref={audioRef}>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AudioItem.propTypes = {
|
||||
participant: PropTypes.object,
|
||||
};
|
||||
|
||||
export const Audio = React.memo(() => {
|
||||
const { allParticipants } = useParticipants();
|
||||
|
||||
return (
|
||||
<>
|
||||
{allParticipants.map(
|
||||
(p) => !p.isLocal && <AudioItem participant={p} key={p.id} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Audio;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Audio as default } from './Audio';
|
||||
export { Audio } from './Audio';
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import Button from '@dailyjs/shared/components/Button';
|
||||
import { TextInput } from '@dailyjs/shared/components/Input';
|
||||
import Loader from '@dailyjs/shared/components/Loader';
|
||||
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
|
||||
import { Tile } from '@dailyjs/shared/components/Tile';
|
||||
import { ACCESS_STATE_LOBBY } from '@dailyjs/shared/constants';
|
||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import {
|
||||
DEVICE_STATE_BLOCKED,
|
||||
DEVICE_STATE_NOT_FOUND,
|
||||
DEVICE_STATE_IN_USE,
|
||||
DEVICE_STATE_PENDING,
|
||||
DEVICE_STATE_LOADING,
|
||||
DEVICE_STATE_GRANTED,
|
||||
} from '@dailyjs/shared/contexts/useDevices';
|
||||
import IconSettings from '@dailyjs/shared/icons/settings-sm.svg';
|
||||
|
||||
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||
|
||||
export const HairCheck = () => {
|
||||
const { callObject } = useCallState();
|
||||
const { localParticipant } = useParticipants();
|
||||
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
|
||||
useMediaDevices();
|
||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [userName, setUserName] = useState('');
|
||||
|
||||
// Tell Daily to initialise devices (even through we're not yet in a call)
|
||||
useEffect(() => {
|
||||
if (!callObject) return;
|
||||
callObject.startCamera();
|
||||
}, [callObject]);
|
||||
|
||||
const joinCall = useCallback(async () => {
|
||||
if (!callObject) return;
|
||||
|
||||
// Disable join controls
|
||||
setJoining(true);
|
||||
|
||||
// Set the local participants name
|
||||
await callObject.setUserName(userName);
|
||||
|
||||
// Attempt to jin the call
|
||||
const { access } = callObject.accessState();
|
||||
await callObject.join();
|
||||
|
||||
// If we we're in the lobby, wait for the owner to let us in
|
||||
if (access?.level === ACCESS_STATE_LOBBY) {
|
||||
setWaiting(true);
|
||||
const { granted } = await callObject.requestAccess({
|
||||
name: localParticipant?.name,
|
||||
access: {
|
||||
level: 'full',
|
||||
},
|
||||
});
|
||||
|
||||
if (granted) {
|
||||
console.log('👋 Access granted');
|
||||
} else {
|
||||
console.log('❌ Access denied');
|
||||
}
|
||||
}
|
||||
}, [callObject, userName, localParticipant]);
|
||||
|
||||
// Memoize the to prevent unnecassary re-renders
|
||||
const tileMemo = useDeepCompareMemo(
|
||||
() => (
|
||||
<Tile
|
||||
participant={localParticipant}
|
||||
mirrored
|
||||
showAvatar
|
||||
showName={false}
|
||||
/>
|
||||
),
|
||||
[localParticipant]
|
||||
);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => deviceState === DEVICE_STATE_LOADING,
|
||||
[deviceState]
|
||||
);
|
||||
|
||||
const hasError = useMemo(() => {
|
||||
if (
|
||||
!deviceState ||
|
||||
[
|
||||
DEVICE_STATE_LOADING,
|
||||
DEVICE_STATE_PENDING,
|
||||
DEVICE_STATE_GRANTED,
|
||||
].includes(deviceState)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [deviceState]);
|
||||
|
||||
const camErrorVerbose = useMemo(() => {
|
||||
switch (camError) {
|
||||
case DEVICE_STATE_BLOCKED:
|
||||
return 'Camera blocked by user';
|
||||
case DEVICE_STATE_NOT_FOUND:
|
||||
return 'Camera not found';
|
||||
case DEVICE_STATE_IN_USE:
|
||||
return 'Device in use';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}, [camError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="haircheck">
|
||||
<img
|
||||
src="/images/daily-logo.svg"
|
||||
alt="Daily.co"
|
||||
width="132"
|
||||
height="58"
|
||||
className="logo"
|
||||
/>
|
||||
<div className="panel">
|
||||
<header>
|
||||
<h2>Ready to join?</h2>
|
||||
</header>
|
||||
<div className="tile-container">
|
||||
<div className="content">
|
||||
<Button
|
||||
className="device-button"
|
||||
size="medium-square"
|
||||
variant="blur"
|
||||
onClick={() => setShowDeviceModal(!showDeviceModal)}
|
||||
>
|
||||
<IconSettings />
|
||||
</Button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="overlay-message">
|
||||
Loading devices, please wait...
|
||||
</div>
|
||||
)}
|
||||
{hasError && (
|
||||
<>
|
||||
{camError && (
|
||||
<div className="overlay-message">{camErrorVerbose}</div>
|
||||
)}
|
||||
{micError && (
|
||||
<div className="overlay-message">{micError}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mute-buttons">
|
||||
<MuteButton isMuted={isCamMuted} />
|
||||
<MuteButton mic isMuted={isMicMuted} />
|
||||
</div>
|
||||
{tileMemo}
|
||||
</div>
|
||||
<footer>
|
||||
{waiting ? (
|
||||
<div className="waiting">
|
||||
<Loader /> <span>Waiting for host to grant access</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Enter display name"
|
||||
variant="dark"
|
||||
disabled={joining}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={joining || userName.length < 3}
|
||||
onClick={() => joinCall(userName)}
|
||||
>
|
||||
Join call
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.haircheck {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: url('/images/pattern-bg.png') center center no-repeat;
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
.haircheck .panel {
|
||||
width: 720px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.haircheck .tile-container {
|
||||
border-radius: var(--radius-md);
|
||||
-webkit-mask-image: -webkit-radial-gradient(white, black);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.haircheck header {
|
||||
position: relative;
|
||||
color: white;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
border-bottom: 0px;
|
||||
padding: var(--spacing-md) 0 calc(6px + var(--spacing-md)) 0;
|
||||
}
|
||||
|
||||
.haircheck header:before,
|
||||
.haircheck footer:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 6px;
|
||||
left: var(--spacing-sm);
|
||||
right: var(--spacing-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--primary-default) 0%,
|
||||
var(--secondary-dark) 100%
|
||||
);
|
||||
border-radius: 6px 6px 0px 0px;
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.haircheck footer:before {
|
||||
top: 0px;
|
||||
bottom: auto;
|
||||
border-radius: 0px 0px 6px 6px;
|
||||
}
|
||||
|
||||
.haircheck header h2 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.haircheck .content {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.haircheck .mute-buttons {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
z-index: 99;
|
||||
padding: var(--spacing-sm);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.haircheck .content :global(.device-button) {
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.haircheck .overlay-message {
|
||||
color: var(--reverse);
|
||||
padding: var(--spacing-xxs) var(--spacing-xs);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.haircheck footer {
|
||||
position: relative;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
padding: calc(6px + var(--spacing-md)) var(--spacing-sm)
|
||||
var(--spacing-md) var(--spacing-sm);
|
||||
border-top: 0px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-column-gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waiting span {
|
||||
margin-left: var(--spacing-xxs);
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
left: var(--spacing-sm);
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HairCheck;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { HairCheck as default } from './HairCheck';
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '@dailyjs/shared/components/Button';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from '@dailyjs/shared/components/Card';
|
||||
import Field from '@dailyjs/shared/components/Field';
|
||||
import { TextInput, BooleanInput } from '@dailyjs/shared/components/Input';
|
||||
import { Well } from '@dailyjs/shared/components/Well';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Intro
|
||||
* ---
|
||||
* Specify which room we would like to join
|
||||
*/
|
||||
export const Intro = ({ room, error, domain, onJoin, fetching = false }) => {
|
||||
const [roomName, setRoomName] = useState();
|
||||
const [owner, setOwner] = useState(false);
|
||||
const [fetchToken, setFetchToken] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRoomName(room);
|
||||
}, [room]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>Daily Basic Call Example</CardHeader>
|
||||
<CardBody>
|
||||
{error && (
|
||||
<Well variant="error">
|
||||
Failed to obtain token <p>{error}</p>
|
||||
</Well>
|
||||
)}
|
||||
<Field label="Enter room to join">
|
||||
<TextInput
|
||||
type="text"
|
||||
prefix={`${domain}.daily.co/`}
|
||||
placeholder="Room name"
|
||||
defaultValue={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Fetch meeting token">
|
||||
<BooleanInput onChange={(e) => setFetchToken(e.target.checked)} />
|
||||
</Field>
|
||||
<Field label="Join as owner">
|
||||
<BooleanInput onChange={(e) => setOwner(e.target.checked)} />
|
||||
</Field>
|
||||
</CardBody>
|
||||
<CardFooter divider>
|
||||
<Button
|
||||
onClick={() => onJoin(roomName, owner, fetchToken)}
|
||||
disabled={!roomName || fetching}
|
||||
>
|
||||
{fetching ? 'Fetching token...' : 'Join meeting'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Intro.propTypes = {
|
||||
room: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
domain: PropTypes.string.isRequired,
|
||||
onJoin: PropTypes.func.isRequired,
|
||||
fetching: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Intro;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { Card, CardBody, CardHeader } from '@dailyjs/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,3 @@
|
|||
export { Intro as default } from './Intro';
|
||||
export { Intro } from './Intro';
|
||||
export { NotConfigured } from './NotConfigured';
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import Button from '@dailyjs/shared/components/Button';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from '@dailyjs/shared/components/Card';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const MessageCard = ({ header, children, error = false, onBack }) => (
|
||||
<Card>
|
||||
{header && <CardHeader>{header}</CardHeader>}
|
||||
{children && <CardBody>{children}</CardBody>}
|
||||
<CardFooter>
|
||||
{onBack ? (
|
||||
<Button onClick={() => onBack()}>Go back</Button>
|
||||
) : (
|
||||
<Button href="/">Go back</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
{error && (
|
||||
<style jsx>{`
|
||||
.card {
|
||||
border: 3px solid var(--red-default);
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
MessageCard.propTypes = {
|
||||
header: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
error: PropTypes.bool,
|
||||
onBack: PropTypes.func,
|
||||
};
|
||||
|
||||
export default MessageCard;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { MessageCard as default } from './MessageCard';
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
|
||||
export const Header = () => {
|
||||
const { participantCount } = useParticipants();
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<header className="room-header">
|
||||
<img src="images/daily-logo.svg" alt="Daily" className="logo" />
|
||||
<div className="capsule">Basic call demo</div>
|
||||
<div className="capsule">
|
||||
{`${participantCount} ${
|
||||
participantCount > 1 ? 'participants' : 'participant'
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.room-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
column-gap: var(--spacing-xxs);
|
||||
box-sizing: border-box;
|
||||
padding: var(--spacing-sm);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.capsule {
|
||||
background-color: var(--blue-dark);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-xxs) var(--spacing-xs);
|
||||
box-sizing: border-box;
|
||||
line-height: 1;
|
||||
font-weight: var(--weight-medium);
|
||||
user-select: none;
|
||||
}
|
||||
`}</style>
|
||||
</header>
|
||||
),
|
||||
[participantCount]
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import { ReactComponent as IconAdd } from '@dailyjs/shared/icons/add-md.svg';
|
||||
import { ReactComponent as IconCameraOff } from '@dailyjs/shared/icons/camera-off-md.svg';
|
||||
import { ReactComponent as IconCameraOn } from '@dailyjs/shared/icons/camera-on-md.svg';
|
||||
import { ReactComponent as IconLeave } from '@dailyjs/shared/icons/leave-md.svg';
|
||||
import { ReactComponent as IconMicOff } from '@dailyjs/shared/icons/mic-off-md.svg';
|
||||
import { ReactComponent as IconMicOn } from '@dailyjs/shared/icons/mic-on-md.svg';
|
||||
import { ReactComponent as IconSettings } from '@dailyjs/shared/icons/settings-md.svg';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Audio } from '../Audio';
|
||||
import { VideoGrid } from '../VideoGrid';
|
||||
import { Header } from './Header';
|
||||
import { Tray, TrayButton } from './Tray';
|
||||
|
||||
export const Room = ({ onLeave }) => {
|
||||
const { callObject, addFakeParticipant } = useCallState();
|
||||
const { setShowDeviceModal } = useUIState();
|
||||
const { isCamMuted, isMicMuted } = useMediaDevices();
|
||||
|
||||
const toggleCamera = (newState) => {
|
||||
if (!callObject) return false;
|
||||
return callObject.setLocalVideo(newState);
|
||||
};
|
||||
|
||||
const toggleMic = (newState) => {
|
||||
if (!callObject) return false;
|
||||
return callObject.setLocalAudio(newState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="room">
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
<VideoGrid />
|
||||
</main>
|
||||
|
||||
<Tray>
|
||||
<TrayButton
|
||||
label="Camera"
|
||||
onClick={() => toggleCamera(isCamMuted)}
|
||||
orange={isCamMuted}
|
||||
>
|
||||
{isCamMuted ? <IconCameraOff /> : <IconCameraOn />}
|
||||
</TrayButton>
|
||||
<TrayButton
|
||||
label="Mic"
|
||||
onClick={() => toggleMic(isMicMuted)}
|
||||
orange={isMicMuted}
|
||||
>
|
||||
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
||||
</TrayButton>
|
||||
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
|
||||
<IconSettings />
|
||||
</TrayButton>
|
||||
<TrayButton label="Add fake" onClick={() => addFakeParticipant()}>
|
||||
<IconAdd />
|
||||
</TrayButton>
|
||||
<span className="divider" />
|
||||
<TrayButton label="Leave" onClick={onLeave} orange>
|
||||
<IconLeave />
|
||||
</TrayButton>
|
||||
</Tray>
|
||||
|
||||
<Audio />
|
||||
|
||||
<style jsx>{`
|
||||
.room {
|
||||
flex-flow: column nowrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0px;
|
||||
height: 100%;
|
||||
padding: var(--spacing-xxxs);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Room.propTypes = {
|
||||
onLeave: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Room;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import Button from '@dailyjs/shared/components/Button';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const TrayButton = ({ children, label, onClick, orange = false }) => (
|
||||
<div className={orange ? 'tray-button orange' : 'tray-button'}>
|
||||
<Button onClick={() => onClick()} variant="dark" size="large-square">
|
||||
{children}
|
||||
</Button>
|
||||
<span>{label}</span>
|
||||
|
||||
<style jsx>{`
|
||||
.tray-button {
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tray-button.orange :global(.button) {
|
||||
color: var(--secondary-dark);
|
||||
}
|
||||
|
||||
span {
|
||||
color: white;
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 12px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
TrayButton.propTypes = {
|
||||
children: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
orange: PropTypes.bool,
|
||||
label: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export const Tray = ({ children }) => (
|
||||
<footer>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
footer {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--spacing-xs);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xxs);
|
||||
}
|
||||
|
||||
footer :global(.divider) {
|
||||
width: 1px;
|
||||
height: 52px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</footer>
|
||||
);
|
||||
|
||||
Tray.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Tray;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { Room as default } from './Room';
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import Tile from '@dailyjs/shared/components/Tile';
|
||||
import { DEFAULT_ASPECT_RATIO } from '@dailyjs/shared/constants';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { useTracks } from '@dailyjs/shared/contexts/TracksProvider';
|
||||
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||
|
||||
export const VideoGrid = React.memo(
|
||||
() => {
|
||||
const containerRef = useRef();
|
||||
const { allParticipants } = useParticipants();
|
||||
const { resumeVideoTrack } = useTracks();
|
||||
const [dimensions, setDimensions] = useState({
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let frame;
|
||||
const handleResize = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() =>
|
||||
setDimensions({
|
||||
width: containerRef.current?.clientWidth,
|
||||
height: containerRef.current?.clientHeight,
|
||||
})
|
||||
);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const aspectRatio = DEFAULT_ASPECT_RATIO;
|
||||
const tileCount = allParticipants.length || 0;
|
||||
const w = dimensions.width;
|
||||
const h = dimensions.height;
|
||||
|
||||
// brute-force search layout where video occupy the largest area of the container
|
||||
let bestLayout = {
|
||||
area: 0,
|
||||
cols: 0,
|
||||
rows: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
for (let cols = 0; cols <= tileCount; cols += 1) {
|
||||
const rows = Math.ceil(tileCount / cols);
|
||||
const hScale = w / (cols * aspectRatio);
|
||||
const vScale = h / rows;
|
||||
let width;
|
||||
let height;
|
||||
if (hScale <= vScale) {
|
||||
width = Math.floor(w / cols);
|
||||
height = Math.floor(width / aspectRatio);
|
||||
} else {
|
||||
height = Math.floor(h / rows);
|
||||
width = Math.floor(height * aspectRatio);
|
||||
}
|
||||
const area = width * height;
|
||||
if (area > bestLayout.area) {
|
||||
bestLayout = {
|
||||
area,
|
||||
width,
|
||||
height,
|
||||
rows,
|
||||
cols,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bestLayout;
|
||||
}, [dimensions, allParticipants]);
|
||||
|
||||
const tiles = useDeepCompareMemo(
|
||||
() =>
|
||||
allParticipants.map((p) => (
|
||||
<Tile
|
||||
participant={p}
|
||||
key={p.id}
|
||||
mirrored
|
||||
style={{ maxWidth: layout.width, maxHeight: layout.height }}
|
||||
/>
|
||||
)),
|
||||
[layout, allParticipants]
|
||||
);
|
||||
|
||||
/**
|
||||
* Set bandwidth layer based on amount of visible participants
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof rtcpeers === 'undefined' ||
|
||||
// eslint-disable-next-line no-undef
|
||||
rtcpeers?.getCurrentType() !== 'sfu'
|
||||
)
|
||||
return;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const sfu = rtcpeers.soup;
|
||||
const count = allParticipants.length;
|
||||
|
||||
allParticipants.forEach(({ id }) => {
|
||||
if (count < 5) {
|
||||
// High quality video for calls with < 5 people per page
|
||||
sfu.setPreferredLayerForTrack(id, 'cam-video', 2);
|
||||
} else if (count < 10) {
|
||||
// Medium quality video for calls with < 10 people per page
|
||||
sfu.setPreferredLayerForTrack(id, 'cam-video', 1);
|
||||
} else {
|
||||
// Low quality video for calls with 10 or more people per page
|
||||
sfu.setPreferredLayerForTrack(id, 'cam-video', 0);
|
||||
}
|
||||
});
|
||||
}, [allParticipants]);
|
||||
|
||||
useEffect(() => {
|
||||
allParticipants.forEach(
|
||||
(p) => p.id !== 'local' && resumeVideoTrack(p.id)
|
||||
);
|
||||
}, [allParticipants, resumeVideoTrack]);
|
||||
|
||||
if (!allParticipants.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="video-grid" ref={containerRef}>
|
||||
<div className="tiles">{tiles}</div>
|
||||
<style jsx>{`
|
||||
.video-grid {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-grid .tiles {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
max-height: 100%;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true
|
||||
);
|
||||
|
||||
export default VideoGrid;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { VideoGrid as default } from './VideoGrid';
|
||||
export { VideoGrid } from './VideoGrid';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
|
||||
DAILY_DOMAIN=
|
||||
|
||||
# Obtained from https://dashboard.staging.daily.co/developers
|
||||
DAILY_API_KEY=
|
||||
|
||||
# Daily REST API endpoint
|
||||
DAILY_REST_DOMAIN=https://api.daily.co/v1
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
|
@ -0,0 +1,4 @@
|
|||
const withPlugins = require('next-compose-plugins');
|
||||
const withTM = require('next-transpile-modules')(['@dailyjs/shared']);
|
||||
|
||||
module.exports = withPlugins([withTM]);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@dailyjs/basic-call",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dailyjs/shared": "*",
|
||||
"debounce": "^1.2.1",
|
||||
"next": "^10.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"next-compose-plugins": "^2.2.1",
|
||||
"next-transpile-modules": "^6.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import GlobalStyle from '@dailyjs/shared/components/GlobalStyle';
|
||||
import Head from 'next/head';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function App({ Component, pageProps }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Daily - Basic Call Example</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</Head>
|
||||
<GlobalStyle />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
App.defaultProps = {
|
||||
Component: null,
|
||||
pageProps: {},
|
||||
};
|
||||
|
||||
App.propTypes = {
|
||||
Component: PropTypes.elementType,
|
||||
pageProps: PropTypes.object,
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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}/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,121 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { CallProvider } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import { MediaDeviceProvider } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||
import { ParticipantsProvider } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { TracksProvider } from '@dailyjs/shared/contexts/TracksProvider';
|
||||
import { UIStateProvider } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import PropTypes from 'prop-types';
|
||||
import App from '../components/App';
|
||||
import { Intro, NotConfigured } from '../components/Intro';
|
||||
|
||||
/**
|
||||
* 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 [roomName, setRoomName] = useState('');
|
||||
const [fetchingToken, setFetchingToken] = useState(false);
|
||||
const [token, setToken] = useState();
|
||||
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);
|
||||
setToken(resJson.token);
|
||||
|
||||
// Setting room name will change ready state
|
||||
setRoomName(room);
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const isReady = !!(isConfigured && roomName);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<main>
|
||||
{!isConfigured ? (
|
||||
<NotConfigured />
|
||||
) : (
|
||||
<Intro
|
||||
room={roomName}
|
||||
error={tokenError}
|
||||
fetching={fetchingToken}
|
||||
domain={domain}
|
||||
onJoin={(room, isOwner, fetchToken) =>
|
||||
fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 640px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main call UI
|
||||
*/
|
||||
return (
|
||||
<UIStateProvider>
|
||||
<CallProvider domain={domain} room={roomName} token={token}>
|
||||
<ParticipantsProvider>
|
||||
<TracksProvider>
|
||||
<MediaDeviceProvider>
|
||||
<App />
|
||||
</MediaDeviceProvider>
|
||||
</TracksProvider>
|
||||
</ParticipantsProvider>
|
||||
</CallProvider>
|
||||
</UIStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Index.propTypes = {
|
||||
isConfigured: PropTypes.bool.isRequired,
|
||||
domain: PropTypes.string,
|
||||
};
|
||||
|
||||
export async function getStaticProps() {
|
||||
// Check that both domain and key env vars are set
|
||||
const isConfigured =
|
||||
!!process.env.DAILY_DOMAIN && !!process.env.DAILY_API_KEY;
|
||||
|
||||
// Pass through domain as prop
|
||||
return {
|
||||
props: { domain: process.env.DAILY_DOMAIN || null, isConfigured },
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import MessageCard from '../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="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,240 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Link from 'next/link';
|
||||
import PropTypes from 'prop-types';
|
||||
import theme from '../../styles/defaultTheme';
|
||||
import { hexa } from '../../styles/global';
|
||||
|
||||
export const Button = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
fullWidth,
|
||||
href,
|
||||
IconAfter = null,
|
||||
IconBefore = null,
|
||||
loading = false,
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
variant = 'primary',
|
||||
shadow = false,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const cx = classnames('button', className, size, variant, {
|
||||
disabled,
|
||||
fullWidth,
|
||||
loading,
|
||||
shadow,
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{IconBefore && <IconBefore />}
|
||||
{children}
|
||||
{IconAfter && <IconAfter />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<a className={cx} ref={ref} {...rest}>
|
||||
{content}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cx}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type || 'button'}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.button {
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0;
|
||||
height: 48px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
color: var(--text-darkest);
|
||||
background-color: var(--primary-default);
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.button.fullWidth {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button:visited {
|
||||
color: var(--text-darkest);
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus,
|
||||
.button:active,
|
||||
.button[href]:hover,
|
||||
.button[href]:focus,
|
||||
.button[href]:active {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--primary-dark);
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
box-shadow: 0 0 0px 3px ${hexa(theme.primary.default, 0.35)};
|
||||
}
|
||||
|
||||
.button::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
border-color: var(--gray-default);
|
||||
background-color: var(--gray-default);
|
||||
color: var(--gray-dark);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.shadow {
|
||||
box-shadow: 0 0 4px 0 rgb(0 0 0 / 8%), 0 4px 4px 0 rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.button.shadow:hover {
|
||||
box-shadow: 0 0 4px 0 rgb(0 0 0 / 8%), 0 4px 4px 0 rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.button.medium-square {
|
||||
padding: 0px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.button.large-square {
|
||||
padding: 0px;
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.button.large-circle {
|
||||
padding: 0px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.button.translucent {
|
||||
background: ${hexa(theme.blue.light, 0.35)};
|
||||
color: white;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.button.translucent:hover,
|
||||
.button.translucent:focus,
|
||||
.button.translucent:active {
|
||||
border: 0px;
|
||||
box-shadow: none;
|
||||
background: ${hexa(theme.blue.light, 1)};
|
||||
}
|
||||
|
||||
.button.blur {
|
||||
background: ${hexa(theme.blue.default, 0.35)};
|
||||
backdrop-filter: blur(10px);
|
||||
color: white;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.button.blur:hover,
|
||||
.button.blur:focus,
|
||||
.button.blur:active {
|
||||
border: 0px;
|
||||
box-shadow: none;
|
||||
background: ${hexa(theme.blue.default, 1)};
|
||||
}
|
||||
|
||||
.button.blur:focus {
|
||||
box-shadow: 0 0 0px 3px ${hexa(theme.blue.default, 0.35)};
|
||||
}
|
||||
|
||||
.button.dark {
|
||||
background: ${theme.blue.dark};
|
||||
color: white;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.button.dark:hover,
|
||||
.button.dark:focus,
|
||||
.button.dark:active {
|
||||
background: ${theme.blue.default};
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.button.dark:focus {
|
||||
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.button.outline {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.button.outline:hover,
|
||||
.button.outline:focus,
|
||||
.button.outline:active {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: none;
|
||||
}
|
||||
.button.outline:focus {
|
||||
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.button.muted {
|
||||
color: var(--red-default);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
fullWidth: PropTypes.bool,
|
||||
href: PropTypes.string,
|
||||
IconAfter: PropTypes.node,
|
||||
IconBefore: PropTypes.node,
|
||||
loading: PropTypes.bool,
|
||||
size: PropTypes.string,
|
||||
type: PropTypes.oneOf(['button', 'reset', 'submit']),
|
||||
variant: PropTypes.string,
|
||||
shadow: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Button } from './Button';
|
||||
export { Button as default } from './Button';
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const Card = ({ children }) => (
|
||||
<div className="card">
|
||||
{children}
|
||||
<style jsx>{`
|
||||
background: white;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
Card.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const CardHeader = ({ children }) => (
|
||||
<header className="card-header">
|
||||
<h2>{children}</h2>
|
||||
<style jsx>{`
|
||||
h2 {
|
||||
font-size: 1.375rem;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
& + :global(.card-body) {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
`}</style>
|
||||
</header>
|
||||
);
|
||||
CardHeader.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const CardBody = ({ children }) => (
|
||||
<div className="card-body">
|
||||
{children}
|
||||
<style jsx>{`
|
||||
color: var(--text-mid);
|
||||
|
||||
& + :global(.card-footer) {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
CardBody.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const CardFooter = ({ children, divider = false }) => (
|
||||
<footer className={classNames('card-footer', { divider })}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
|
||||
&.divider {
|
||||
border-top: 1px solid var(--gray-light);
|
||||
padding-top: var(--spacing-md);
|
||||
}
|
||||
`}</style>
|
||||
</footer>
|
||||
);
|
||||
CardFooter.propTypes = {
|
||||
children: PropTypes.node,
|
||||
divider: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||
import { Field } from '../Field';
|
||||
import { SelectInput } from '../Input';
|
||||
|
||||
export const DeviceSelect = () => {
|
||||
const {
|
||||
cams,
|
||||
mics,
|
||||
speakers,
|
||||
currentDevices,
|
||||
setCamDevice,
|
||||
setMicDevice,
|
||||
setSpeakersDevice,
|
||||
} = useMediaDevices();
|
||||
|
||||
if (!currentDevices) {
|
||||
return <div>Loading devices...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Select camera:">
|
||||
<SelectInput
|
||||
onChange={(e) => setCamDevice(cams[e.target.value])}
|
||||
value={cams.findIndex(
|
||||
(i) => i.deviceId === currentDevices.camera.deviceId
|
||||
)}
|
||||
>
|
||||
{cams.map(({ deviceId, label }, i) => (
|
||||
<option key={`cam-${deviceId}`} value={i}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</SelectInput>
|
||||
</Field>
|
||||
|
||||
<Field label="Select microphone:">
|
||||
<SelectInput
|
||||
onChange={(e) => setMicDevice(mics[e.target.value])}
|
||||
value={mics.findIndex(
|
||||
(i) => i.deviceId === currentDevices.mic.deviceId
|
||||
)}
|
||||
>
|
||||
{mics.map(({ deviceId, label }, i) => (
|
||||
<option key={`mic-${deviceId}`} value={i}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</SelectInput>
|
||||
</Field>
|
||||
|
||||
{/**
|
||||
* Note: Safari does not support selection audio output devices
|
||||
*/}
|
||||
{speakers.length > 0 && (
|
||||
<Field label="Select speakers:">
|
||||
<SelectInput
|
||||
onChange={(e) => setSpeakersDevice(speakers[e.target.value])}
|
||||
value={speakers.findIndex(
|
||||
(i) => i.deviceId === currentDevices.speaker.deviceId
|
||||
)}
|
||||
>
|
||||
{speakers.map(({ deviceId, label }, i) => (
|
||||
<option key={`speakers-${deviceId}`} value={i}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</SelectInput>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceSelect;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { DeviceSelect as default } from './DeviceSelect';
|
||||
export { DeviceSelect } from './DeviceSelect';
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import Modal from '@dailyjs/shared/components/Modal';
|
||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import { Button } from '../Button';
|
||||
import { DeviceSelect } from '../DeviceSelect';
|
||||
|
||||
export const DeviceSelectModal = () => {
|
||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Select your device"
|
||||
isOpen={showDeviceModal}
|
||||
onClose={() => setShowDeviceModal(false)}
|
||||
actions={[
|
||||
<Button fullWidth variant="outline">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button fullWidth>Update</Button>,
|
||||
]}
|
||||
>
|
||||
<DeviceSelect />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceSelectModal;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { DeviceSelectModal as default } from './DeviceSelectModal';
|
||||
export { DeviceSelectModal } from './DeviceSelectModal';
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const Field = ({ label, children }) => (
|
||||
<div className="field">
|
||||
{label && <div className="label">{label}</div>}
|
||||
<div className="field">{children}</div>
|
||||
|
||||
<style jsx>{`
|
||||
.field {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.field .label {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-default);
|
||||
margin-bottom: var(--spacing-xxxs);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
Field.propTypes = {
|
||||
label: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Field;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Field as default } from './Field';
|
||||
export { Field } from './Field';
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import theme from '../../styles/defaultTheme';
|
||||
import { hexa } from '../../styles/global';
|
||||
|
||||
export const GlobalStyle = () => (
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--background: ${theme.background};
|
||||
--reverse: ${theme.reverse};
|
||||
|
||||
--primary-default: ${theme.primary.default};
|
||||
--primary-dark: ${theme.primary.dark};
|
||||
--secondary-default: ${theme.secondary.default};
|
||||
--secondary-dark: ${theme.secondary.dark};
|
||||
--blue-light: ${theme.blue.light};
|
||||
--blue-default: ${theme.blue.default};
|
||||
--blue-dark: ${theme.blue.dark};
|
||||
--green-light: ${theme.green.light};
|
||||
--green-default: ${theme.green.default};
|
||||
--green-dark: ${theme.green.dark};
|
||||
--red-light: ${theme.red.light};
|
||||
--red-default: ${theme.red.default};
|
||||
--red-dark: ${theme.red.dark};
|
||||
--gray-wash: ${theme.gray.wash};
|
||||
--gray-light: ${theme.gray.light};
|
||||
--gray-default: ${theme.gray.default};
|
||||
--gray-dark: ${theme.gray.dark};
|
||||
|
||||
--text-default: ${theme.text.default};
|
||||
--text-reverse: ${theme.text.reverse};
|
||||
--text-mid: ${theme.text.mid};
|
||||
--text-darkest: ${theme.text.darkest};
|
||||
--text-pre: ${theme.text.pre};
|
||||
|
||||
--font-family: 'Rubik', sans-serif;
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-bold: 600;
|
||||
|
||||
--radius-sm: 9px;
|
||||
--radius-md: 12px;
|
||||
|
||||
--spacing-xxxxl: 5.25rem;
|
||||
--spacing-xxxl: 4.5rem;
|
||||
--spacing-xxl: 3.75rem;
|
||||
--spacing-xl: 3rem;
|
||||
--spacing-lg: 2.25rem;
|
||||
--spacing-md: 1.875rem;
|
||||
--spacing-sm: 1.5rem;
|
||||
--spacing-xs: 1.125rem;
|
||||
--spacing-xxs: 0.75rem;
|
||||
--spacing-xxxs: 0.5625rem;
|
||||
--spacing-xxxxs: 0.375rem;
|
||||
|
||||
--shadow-depth-1: 0px 5px 25px rgba(0, 0, 0, 0.07);
|
||||
--shadow-depth-2: 0px 5px 15px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font: normal normal normal 1rem / 1.6 var(--font-family);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-default);
|
||||
background: var(--background);
|
||||
margin: 0px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: var(--spacing-md) 0;
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
a {
|
||||
transition: color 250ms;
|
||||
}
|
||||
|
||||
a,
|
||||
a:active,
|
||||
a:visited {
|
||||
color: var(--text-mid);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--text-darkest);
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
color: var(--text-pre);
|
||||
background-color: ${hexa(theme.text.pre, 0.12)};
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
export default GlobalStyle;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { GlobalStyle as default } from './GlobalStyle';
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import theme from '../../styles/defaultTheme';
|
||||
import { hexa } from '../../styles/global';
|
||||
|
||||
const InputContainer = ({ children, prefix, className }) => (
|
||||
<div className={className}>
|
||||
{prefix && <span>{prefix}</span>}
|
||||
{children}
|
||||
<style jsx>{`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
&.prefix > span {
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
flex: 0;
|
||||
display: flex;
|
||||
border: 1px solid var(--gray-light);
|
||||
border-right: 0px;
|
||||
padding: 0 var(--spacing-xs);
|
||||
background-color: var(--reverse);
|
||||
color: var(--text-mid);
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.prefix + :global(input),
|
||||
&.prefix + :global(select) {
|
||||
border-radius: 0px var(--radius-sm) var(--radius-sm) 0px;
|
||||
}
|
||||
|
||||
:global(input),
|
||||
:global(select) {
|
||||
display: flex;
|
||||
margin: 0px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 0 auto;
|
||||
border: 1px solid var(--gray-light);
|
||||
background-color: var(--gray-wash);
|
||||
border-radius: 9px;
|
||||
height: 48px;
|
||||
max-width: 100%;
|
||||
padding: 0 var(--spacing-xs);
|
||||
outline: none;
|
||||
font-family: var(--font-family);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
transition: all 200ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:global(input)::-webkit-input-placeholder {
|
||||
color: ${hexa(theme.text.default, 0.35)};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(input)::-moz-placeholder {
|
||||
color: ${hexa(theme.text.default, 0.35)};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(input)::-ms-input-placeholder {
|
||||
color: ${hexa(theme.text.default, 0.35)};
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(input):focus,
|
||||
:global(select):focus {
|
||||
background-color: var(--reverse);
|
||||
border-color: var(--gray-default);
|
||||
box-shadow: 0 0 0px 3px ${hexa(theme.gray.default, 0.35)};
|
||||
}
|
||||
|
||||
:global(input):disabled,
|
||||
:global(select):disabled {
|
||||
background: var(--gray-wash);
|
||||
color: var(--text-default);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(select) {
|
||||
line-height: 1.2;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAIZSURBVHgB7VdNSwJBGJ7ddbUooiDq4sVDdFEU9NAxCCIv/QBBEBV/lIgIoj+gixEIHT2paJcgkEAIvBiJoK4fvY/sgoizzo7XfWCYj33neR9mZ3eeUZgkksnkia7rD2gbhlEvl8tjJgGNScBKvlqtTqjr1TTNHwwG+51Ox2AO4VhAIpG48Hq9j9Q8IgFDqidUzkhEIBwO/7Tb7YkTPkcCstlsQFXVe8yj5L1SqfROCb8ikcgpjV0qinITi8XGzWbzV5RTWEAmkwkul8so2pT8g5I3rWckoh+NRjF+TcVPghQaG4jwCgkwk4c2kne3Y1qt1sASQStxJSpir4BUKnVH1e06WNMaxWLxkxcLEaFQaEyvyW+KOMXq2PErvAe5XE6fzWYPRHRBXWM6ndar1eqQCQAb1efz4RPVsVFp09bz+fzOL0Tlkczn8ziSUxlTuyaaHEAs5mAuOMDFi1XtiEAg+5PBHMwFB3PhwoULFy5swD2O0+n0M+pDHO+mcyYf8bIrxvY0hOsFAYiYZHLTOXPBFTAajV5hJkwRT+SMzpkgYEg8Hk8cc8FB7RovVtlHBktGZ3oAbViyQqHQs4uHc14sFrBxzHTODbv4vZ7QieO1c87SAgARxyvinKUFWCJ4jteJc97G3j2wjW3HuyaRcM4WHN8Nu93uhFbiGxdS6p5R8mPTOb9VKpU/5hBSt2PcgnEbhghKbhzys/oHKKNCfC4G3igAAAAASUVORK5CYII=');
|
||||
background-size: 16px 16px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--spacing-xs) center;
|
||||
padding: 0 var(--spacing-xl) 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.dark :global(input) {
|
||||
background: var(--blue-dark);
|
||||
color: var(--reverse);
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.dark :global(input):focus {
|
||||
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dark :global(input)::-webkit-input-placeholder {
|
||||
color: var(--text-mid);
|
||||
opacity: 1;
|
||||
}
|
||||
.dark :global(input)::-moz-placeholder {
|
||||
ccolor: var(--text-mid);
|
||||
opacity: 1;
|
||||
}
|
||||
.dark :global(input)::-ms-input-placeholder {
|
||||
color: var(--text-mid);
|
||||
opacity: 1;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
InputContainer.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
prefix: PropTypes.string,
|
||||
};
|
||||
|
||||
export const TextInput = ({ onChange, prefix, variant, ...rest }) => {
|
||||
const cx = classNames('input-container', variant, { prefix });
|
||||
|
||||
return (
|
||||
<InputContainer className={cx} prefix={prefix}>
|
||||
<input type="text" onChange={onChange} {...rest} />
|
||||
</InputContainer>
|
||||
);
|
||||
};
|
||||
|
||||
TextInput.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
prefix: PropTypes.string,
|
||||
variant: PropTypes.string,
|
||||
};
|
||||
|
||||
export const BooleanInput = ({
|
||||
value = false,
|
||||
onChange = () => null,
|
||||
disabled = false,
|
||||
...rest
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(value);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<label disabled={disabled}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setChecked(!checked);
|
||||
onChange(e);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
<span>
|
||||
<div />
|
||||
</span>
|
||||
|
||||
<style jsx>{`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:checked,
|
||||
input:focused {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: var(--green-default);
|
||||
border-color: var(--green-dark);
|
||||
}
|
||||
|
||||
input:checked + span > div {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
& > span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--gray-light);
|
||||
border: 1px solid var(--gray-default);
|
||||
transition: 0.4s;
|
||||
border-radius: 26px;
|
||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||
}
|
||||
|
||||
& > span:hover {
|
||||
border-color: var(--gray-dark);
|
||||
}
|
||||
|
||||
& > span > div {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.1),
|
||||
0px 0px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
BooleanInput.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
};
|
||||
|
||||
export const SelectInput = ({
|
||||
onChange,
|
||||
value,
|
||||
variant,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const cx = classNames('input-container', variant);
|
||||
|
||||
return (
|
||||
<InputContainer className={cx}>
|
||||
<select onChange={onChange} value={value} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
</InputContainer>
|
||||
);
|
||||
};
|
||||
|
||||
SelectInput.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
value: PropTypes.number,
|
||||
variant: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
};
|
||||
|
||||
export default TextInput;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { TextInput as default } from './Input';
|
||||
export { TextInput, BooleanInput, SelectInput } from './Input';
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const Loader = ({ color = 'currentColor', size = 24 }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 44 44"
|
||||
className="loader"
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" strokeWidth="2">
|
||||
<circle cx="22" cy="22" r="19.4775">
|
||||
<animate
|
||||
attributeName="r"
|
||||
begin="0s"
|
||||
dur="1.8s"
|
||||
values="1; 20"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-opacity"
|
||||
begin="0s"
|
||||
dur="1.8s"
|
||||
values="1; 0"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="22" cy="22" r="11.8787">
|
||||
<animate
|
||||
attributeName="r"
|
||||
begin="-0.9s"
|
||||
dur="1.8s"
|
||||
values="1; 20"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-opacity"
|
||||
begin="-0.9s"
|
||||
dur="1.8s"
|
||||
values="1; 0"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
<style jsx>{`
|
||||
svg {
|
||||
display: block;
|
||||
height: ${size}px;
|
||||
stroke: ${color};
|
||||
width: ${size}px;
|
||||
}
|
||||
`}</style>
|
||||
</svg>
|
||||
);
|
||||
|
||||
Loader.propTypes = {
|
||||
size: PropTypes.number,
|
||||
color: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { Loader as default } from './Loader';
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import React, { cloneElement, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import noScroll from 'no-scroll';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ReactComponent as IconClose } from '../../icons/close-sm.svg';
|
||||
import { Button } from '../Button';
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '../Card';
|
||||
|
||||
const transitionMs = 350;
|
||||
|
||||
export const Modal = ({
|
||||
children,
|
||||
actions = [],
|
||||
hideOnBackdropClick = true,
|
||||
locked = false,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
showCloseButton = true,
|
||||
title = null,
|
||||
...props
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(isOpen);
|
||||
const modalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
noScroll.on();
|
||||
}
|
||||
return () => noScroll.off();
|
||||
}, [isOpen]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, transitionMs);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (ev) => {
|
||||
const isBackDropTarget = ev.target === ev.currentTarget;
|
||||
const isFocusOutside = !ev.currentTarget.contains(document.activeElement);
|
||||
// close only if backdrop is actually clicked
|
||||
if (isBackDropTarget && isFocusOutside && hideOnBackdropClick && !locked) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={classNames('backdrop', { isVisible })}
|
||||
onClick={handleBackdropClick}
|
||||
role="presentation"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-modal="true"
|
||||
>
|
||||
{showCloseButton && (
|
||||
<Button
|
||||
size="medium-square"
|
||||
variant="translucent"
|
||||
className="closeButton"
|
||||
onClick={() => close()}
|
||||
>
|
||||
<IconClose />
|
||||
</Button>
|
||||
)}
|
||||
<Card noBorder shadow>
|
||||
{title && <CardHeader>{title}</CardHeader>}
|
||||
<CardBody>{children}</CardBody>
|
||||
</Card>
|
||||
|
||||
{actions.length > 0 && (
|
||||
<CardFooter>
|
||||
{actions.map((child, i) =>
|
||||
cloneElement(child, {
|
||||
key: `action${i}`,
|
||||
onClick: () => {
|
||||
if (child.props?.onClick) {
|
||||
child.props?.onClick?.(close);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
})
|
||||
)}
|
||||
</CardFooter>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: background ${transitionMs}ms ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.backdrop .modal {
|
||||
margin: 40px auto;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity ${transitionMs}ms ease,
|
||||
transform ${transitionMs}ms ease;
|
||||
width: 90vw;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.backdrop .modal :global(.card-footer) {
|
||||
border-top: 0px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
grid-column-gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.isVisible {
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.isVisible .modal {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.backdrop :global(.closeButton) {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: calc(0px - 48px - var(--spacing-xs));
|
||||
}
|
||||
`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Modal, (p, n) => p.open === n.open);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { Modal as default } from './Modal';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ReactComponent as IconCameraOff } from '@dailyjs/shared/icons/camera-off-md.svg';
|
||||
import { ReactComponent as IconCameraOn } from '@dailyjs/shared/icons/camera-on-md.svg';
|
||||
import { ReactComponent as IconMicOff } from '@dailyjs/shared/icons/mic-off-md.svg';
|
||||
import { ReactComponent as IconMicOn } from '@dailyjs/shared/icons/mic-on-md.svg';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallState } from '../../contexts/CallProvider';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export const MuteButton = ({ isMuted, mic = false, className, ...props }) => {
|
||||
const { callObject } = useCallState();
|
||||
const [muted, setMuted] = useState(!isMuted);
|
||||
|
||||
const toggleDevice = (newState) => {
|
||||
if (mic) {
|
||||
callObject.setLocalAudio(newState);
|
||||
} else {
|
||||
callObject.setLocalVideo(newState);
|
||||
}
|
||||
|
||||
setMuted(newState);
|
||||
};
|
||||
|
||||
const Icon = mic
|
||||
? [<IconMicOff />, <IconMicOn />]
|
||||
: [<IconCameraOff />, <IconCameraOn />];
|
||||
|
||||
if (!callObject) return null;
|
||||
|
||||
const cx = classNames(className, { muted: !muted });
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="large-circle"
|
||||
variant="blur"
|
||||
className={cx}
|
||||
{...props}
|
||||
onClick={() => toggleDevice(!muted)}
|
||||
>
|
||||
{Icon[+muted]}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
MuteButton.propTypes = {
|
||||
isMuted: PropTypes.bool,
|
||||
mic: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default MuteButton;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { MuteButton as default } from './MuteButton';
|
||||
export { MuteButton } from './MuteButton';
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import React, { useRef } from 'react';
|
||||
import useVideoTrack from '@dailyjs/shared/hooks/useVideoTrack';
|
||||
import { ReactComponent as IconMicMute } from '@dailyjs/shared/icons/mic-off-sm.svg';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DEFAULT_ASPECT_RATIO } from '../../constants';
|
||||
import { Video } from './Video';
|
||||
import { ReactComponent as Avatar } from './avatar.svg';
|
||||
|
||||
export const Tile = React.memo(
|
||||
({
|
||||
participant,
|
||||
mirrored = true,
|
||||
showName = true,
|
||||
showAvatar = true,
|
||||
aspectRatio = DEFAULT_ASPECT_RATIO,
|
||||
...props
|
||||
}) => {
|
||||
const videoTrack = useVideoTrack(participant);
|
||||
const videoEl = useRef(null);
|
||||
|
||||
const cx = classNames('tile', {
|
||||
mirrored,
|
||||
avatar: showAvatar && !videoTrack,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx} {...props}>
|
||||
<div className="content">
|
||||
{showName && (
|
||||
<div className="name">
|
||||
{participant.isMicMuted && <IconMicMute />}
|
||||
{participant.name}
|
||||
</div>
|
||||
)}
|
||||
{videoTrack ? (
|
||||
<Video ref={videoEl} videoTrack={videoTrack} />
|
||||
) : (
|
||||
showAvatar && (
|
||||
<div className="avatar">
|
||||
<Avatar style={{ width: '35%', height: '35%' }} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.tile .content {
|
||||
padding-bottom: ${100 / aspectRatio}%;
|
||||
}
|
||||
@supports (aspect-ratio: 1 / 1) {
|
||||
.tile .content {
|
||||
aspect-ratio: ${aspectRatio};
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<style jsx>{`
|
||||
.tile {
|
||||
background: var(--blue-dark);
|
||||
min-width: 1px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile.mirrored :global(video) {
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
.tile .name {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
left: 0px;
|
||||
z-index: 2;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
font-weight: var(--weight-medium);
|
||||
padding: var(--spacing-xxs);
|
||||
text-shadow: 0px 1px 3px rgba(0, 0, 0, 0.35);
|
||||
gap: var(--spacing-xxs);
|
||||
}
|
||||
|
||||
.tile .name :global(svg) {
|
||||
color: var(--red-default);
|
||||
}
|
||||
|
||||
.tile :global(video) {
|
||||
object-position: center;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tile .avatar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tile.propTypes = {
|
||||
participant: PropTypes.object.isRequired,
|
||||
mirrored: PropTypes.bool,
|
||||
showName: PropTypes.bool,
|
||||
showAvatar: PropTypes.bool,
|
||||
aspectRatio: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Tile;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import React, { forwardRef, memo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { shallowEqualObjects } from 'shallow-equal';
|
||||
|
||||
export const Video = memo(
|
||||
forwardRef(({ videoTrack, ...rest }, videoEl) => {
|
||||
/**
|
||||
* Effect: mount source
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!videoEl?.current) return;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
videoEl.current.srcObject = new MediaStream([videoTrack]);
|
||||
}, [videoEl, videoTrack]);
|
||||
|
||||
/**
|
||||
* Effect: unmount
|
||||
*/
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (videoEl?.current?.srcObject) {
|
||||
videoEl.current.srcObject.getVideoTracks().forEach((t) => t.stop());
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
videoEl.current.srcObject = null;
|
||||
}
|
||||
},
|
||||
[videoEl]
|
||||
);
|
||||
|
||||
return <video autoPlay muted playsInline ref={videoEl} {...rest} />;
|
||||
}),
|
||||
(p, n) => shallowEqualObjects(p, n)
|
||||
);
|
||||
|
||||
Video.propTypes = {
|
||||
videoTrack: PropTypes.any,
|
||||
mirrored: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Video;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Video as default } from './Video';
|
||||
export { Video } from './Video';
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="100%" viewBox="0 0 87 87" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="43.75" cy="43.5" r="43" fill="#1F2D3D"/>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M43.75 59.5C39.5188 59.6114 35.3061 58.9031 31.344 57.414C31.1672 57.3354 31.0169 57.2073 30.9115 57.0451C30.8061 56.8828 30.75 56.6935 30.75 56.5C30.7529 53.8487 31.8074 51.3069 33.6821 49.4321C35.5569 47.5574 38.0987 46.5029 40.75 46.5H46.75C49.4013 46.5029 51.9431 47.5574 53.8179 49.4321C55.6926 51.3069 56.7471 53.8487 56.75 56.5C56.75 56.6935 56.6939 56.8828 56.5885 57.0451C56.4831 57.2073 56.3328 57.3354 56.156 57.414C52.1939 58.9031 47.9812 59.6114 43.75 59.5Z" fill="#7B848F"/>
|
||||
<path d="M43.75 44.5C39.171 44.5 35.75 39.749 35.75 35.5C35.75 33.3783 36.5929 31.3434 38.0931 29.8431C39.5934 28.3429 41.6283 27.5 43.75 27.5C45.8717 27.5 47.9066 28.3429 49.4069 29.8431C50.9071 31.3434 51.75 33.3783 51.75 35.5C51.75 39.749 48.329 44.5 43.75 44.5Z" fill="#7B848F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="32" height="32" fill="white" transform="translate(27.75 27.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,2 @@
|
|||
export { Tile as default } from './Tile';
|
||||
export { Tile } from './Tile';
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const Well = ({ children, variant }) => (
|
||||
<div className={classNames('well', variant)}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
|
||||
.error {
|
||||
background-color: var(--red-light);
|
||||
color: var(--red-default);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
Well.propTypes = {
|
||||
children: PropTypes.node,
|
||||
variant: PropTypes.oneOf(['error', 'warning', 'info']),
|
||||
};
|
||||
|
||||
export default Well;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Well as default } from './Well';
|
||||
export { Well } from './Well';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export const ACCESS_STATE_UNKNOWN = 'unknown';
|
||||
export const ACCESS_STATE_LOBBY = 'lobby';
|
||||
export const ACCESS_STATE_FULL = 'full';
|
||||
export const ACCESS_STATE_NONE = 'none';
|
||||
|
||||
export const MEETING_STATE_JOINED = 'joined-meeting';
|
||||
|
||||
export const VIDEO_QUALITY_AUTO = 'auto';
|
||||
export const VIDEO_QUALITY_HIGH = 'high';
|
||||
export const VIDEO_QUALITY_LOW = 'low';
|
||||
export const VIDEO_QUALITY_VERY_LOW = 'very-low';
|
||||
export const VIDEO_QUALITY_BANDWIDTH_SAVER = 'bandwidth-saver';
|
||||
|
||||
export const DEFAULT_ASPECT_RATIO = 16 / 9;
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Call Provider / Context
|
||||
* ---
|
||||
* Configures the general state of a Daily call, such as which features
|
||||
* to enable, as well as instantiate the 'call machine' hook responsible
|
||||
* fir the overaching call loop (joining, leaving, etc)
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ACCESS_STATE_LOBBY,
|
||||
ACCESS_STATE_UNKNOWN,
|
||||
VIDEO_QUALITY_AUTO,
|
||||
} from '../constants';
|
||||
import { useCallMachine } from './useCallMachine';
|
||||
|
||||
export const CallContext = createContext();
|
||||
|
||||
export const CallProvider = ({
|
||||
children,
|
||||
domain,
|
||||
room,
|
||||
token = '',
|
||||
subscribeToTracksAutomatically = false,
|
||||
}) => {
|
||||
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
|
||||
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
|
||||
|
||||
// Daily CallMachine hook (primarily handles status of the call)
|
||||
const { daily, leave, join, state } = useCallMachine({
|
||||
domain,
|
||||
room,
|
||||
token,
|
||||
subscribeToTracksAutomatically,
|
||||
});
|
||||
|
||||
// Convience wrapper for adding a fake participant to the call
|
||||
const addFakeParticipant = useCallback(() => {
|
||||
daily.addFakeParticipant();
|
||||
}, [daily]);
|
||||
|
||||
// Convenience wrapper for changing the bandwidth of the client
|
||||
const setBandwidth = useCallback(
|
||||
(quality) => {
|
||||
daily.setBandwidth(quality);
|
||||
},
|
||||
[daily]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!daily) return;
|
||||
|
||||
const { access } = daily.accessState();
|
||||
if (access === ACCESS_STATE_UNKNOWN) return;
|
||||
|
||||
const requiresPermission = access?.level === ACCESS_STATE_LOBBY;
|
||||
setPreJoinNonAuthorized(requiresPermission && !token);
|
||||
}, [state, daily, token]);
|
||||
|
||||
return (
|
||||
<CallContext.Provider
|
||||
value={{
|
||||
state,
|
||||
callObject: daily,
|
||||
addFakeParticipant,
|
||||
preJoinNonAuthorized,
|
||||
leave,
|
||||
join,
|
||||
videoQuality,
|
||||
setVideoQuality,
|
||||
setBandwidth,
|
||||
subscribeToTracksAutomatically,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CallContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
CallProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
domain: PropTypes.string.isRequired,
|
||||
room: PropTypes.string.isRequired,
|
||||
token: PropTypes.string,
|
||||
subscribeToTracksAutomatically: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const useCallState = () => useContext(CallContext);
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useState, useEffect, createContext, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useCallState } from './CallProvider';
|
||||
import { useDevices } from './useDevices';
|
||||
|
||||
export const MediaDeviceContext = createContext();
|
||||
|
||||
export const MediaDeviceProvider = ({ children }) => {
|
||||
const { callObject } = useCallState();
|
||||
const [isCamMuted, setIsCamMuted] = useState(false);
|
||||
const [isMicMuted, setIsMicMuted] = useState(false);
|
||||
|
||||
const {
|
||||
cams,
|
||||
mics,
|
||||
speakers,
|
||||
camError,
|
||||
micError,
|
||||
currentDevices,
|
||||
deviceState,
|
||||
setMicDevice,
|
||||
setCamDevice,
|
||||
setSpeakersDevice,
|
||||
} = useDevices(callObject);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
|
||||
const handleNewDeviceState = () => {
|
||||
setIsCamMuted(!callObject.participants()?.local?.video);
|
||||
setIsMicMuted(!callObject.participants()?.local?.audio);
|
||||
};
|
||||
|
||||
callObject.on('participant-updated', handleNewDeviceState);
|
||||
return () => {
|
||||
callObject.off('participant-updated', handleNewDeviceState);
|
||||
};
|
||||
}, [callObject]);
|
||||
|
||||
return (
|
||||
<MediaDeviceContext.Provider
|
||||
value={{
|
||||
cams,
|
||||
mics,
|
||||
speakers,
|
||||
camError,
|
||||
micError,
|
||||
currentDevices,
|
||||
deviceState,
|
||||
isCamMuted,
|
||||
isMicMuted,
|
||||
setMicDevice,
|
||||
setCamDevice,
|
||||
setSpeakersDevice,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaDeviceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
MediaDeviceProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
MediaDeviceProvider.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export const useMediaDevices = () => useContext(MediaDeviceContext);
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||
|
||||
import { sortByKey } from '../lib/sortByKey';
|
||||
|
||||
import { useCallState } from './CallProvider';
|
||||
import {
|
||||
ACTIVE_SPEAKER,
|
||||
initialParticipantsState,
|
||||
PARTICIPANT_JOINED,
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_UPDATED,
|
||||
participantsReducer,
|
||||
SWAP_POSITION,
|
||||
} from './participantsState';
|
||||
|
||||
export const ParticipantsContext = createContext();
|
||||
|
||||
export const ParticipantsProvider = ({ children }) => {
|
||||
const { broadcast, callObject } = useCallState();
|
||||
const [state, dispatch] = useReducer(
|
||||
participantsReducer,
|
||||
initialParticipantsState
|
||||
);
|
||||
const [participantMarkedForRemoval, setParticipantMarkedForRemoval] =
|
||||
useState(null);
|
||||
|
||||
/**
|
||||
* ALL participants (incl. shared screens) in a convenient array
|
||||
*/
|
||||
const allParticipants = useDeepCompareMemo(
|
||||
() => Object.values(state.participants),
|
||||
[state?.participants]
|
||||
);
|
||||
|
||||
/**
|
||||
* Only return participants that should be visible in the call
|
||||
*/
|
||||
const participants = useDeepCompareMemo(
|
||||
() =>
|
||||
!broadcast
|
||||
? allParticipants
|
||||
: allParticipants.filter((p) => p?.isOwner || p?.isScreenshare),
|
||||
[broadcast, allParticipants]
|
||||
);
|
||||
|
||||
/**
|
||||
* The number of participants, who are not a shared screen
|
||||
* (technically a shared screen counts as a participant, but we shouldn't tell humans)
|
||||
*/
|
||||
const participantCount = useDeepCompareMemo(
|
||||
() => participants.filter(({ isScreenshare }) => !isScreenshare).length,
|
||||
[participants]
|
||||
);
|
||||
|
||||
/**
|
||||
* The participant who most recently got mentioned via a `active-speaker-change` event
|
||||
*/
|
||||
const activeParticipant = useDeepCompareMemo(
|
||||
() => participants.find(({ isActiveSpeaker }) => isActiveSpeaker),
|
||||
[participants]
|
||||
);
|
||||
|
||||
/**
|
||||
* The local participant
|
||||
*/
|
||||
const localParticipant = useDeepCompareMemo(
|
||||
() =>
|
||||
allParticipants.find(
|
||||
({ isLocal, isScreenshare }) => isLocal && !isScreenshare
|
||||
),
|
||||
[allParticipants]
|
||||
);
|
||||
|
||||
/**
|
||||
* The participant who should be rendered prominently right now
|
||||
*/
|
||||
const currentSpeaker = useMemo(() => {
|
||||
/**
|
||||
* Ensure activeParticipant is still present in the call.
|
||||
* The activeParticipant only updates to a new active participant so
|
||||
* if everyone else is muted when AP leaves, the value will be stale.
|
||||
*/
|
||||
const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
|
||||
|
||||
const displayableParticipants = participants.filter((p) => !p?.isLocal);
|
||||
|
||||
const sorted = displayableParticipants
|
||||
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
|
||||
.reverse();
|
||||
|
||||
return isPresent ? activeParticipant : sorted?.[0] ?? localParticipant;
|
||||
}, [activeParticipant, localParticipant, participants]);
|
||||
|
||||
/**
|
||||
* Screen shares
|
||||
*/
|
||||
const screens = useDeepCompareMemo(
|
||||
() => allParticipants.filter(({ isScreenshare }) => isScreenshare),
|
||||
[allParticipants]
|
||||
);
|
||||
|
||||
/**
|
||||
* The local participant's name
|
||||
*/
|
||||
const username = callObject?.participants()?.local?.user_name ?? '';
|
||||
|
||||
/**
|
||||
* Sets the local participant's name in daily-js
|
||||
* @param name The new username
|
||||
*/
|
||||
const setUsername = (name) => {
|
||||
callObject.setUserName(name);
|
||||
};
|
||||
|
||||
const swapParticipantPosition = (id1, id2) => {
|
||||
dispatch({
|
||||
type: SWAP_POSITION,
|
||||
id1,
|
||||
id2,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewParticipantsState = useCallback(
|
||||
(event = null) => {
|
||||
switch (event?.action) {
|
||||
case 'participant-joined':
|
||||
dispatch({
|
||||
type: PARTICIPANT_JOINED,
|
||||
participant: event.participant,
|
||||
});
|
||||
break;
|
||||
case 'participant-updated':
|
||||
dispatch({
|
||||
type: PARTICIPANT_UPDATED,
|
||||
participant: event.participant,
|
||||
});
|
||||
break;
|
||||
case 'participant-left':
|
||||
dispatch({
|
||||
type: PARTICIPANT_LEFT,
|
||||
participant: event.participant,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* Start listening for participant changes, when the callObject is set.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
|
||||
console.log('👥 Participant provider events bound');
|
||||
|
||||
const events = [
|
||||
'joined-meeting',
|
||||
'participant-joined',
|
||||
'participant-updated',
|
||||
'participant-left',
|
||||
];
|
||||
|
||||
// Use initial state
|
||||
handleNewParticipantsState();
|
||||
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
|
||||
/**
|
||||
* Ignore active-speaker-change events for the local user.
|
||||
* Our UX doesn't ever highlight the local user as the active speaker.
|
||||
*/
|
||||
const localId = callObject.participants().local.session_id;
|
||||
if (localId === activeSpeaker?.peerId) return;
|
||||
|
||||
dispatch({
|
||||
type: ACTIVE_SPEAKER,
|
||||
id: activeSpeaker?.peerId,
|
||||
});
|
||||
};
|
||||
callObject.on('active-speaker-change', handleActiveSpeakerChange);
|
||||
return () =>
|
||||
callObject.off('active-speaker-change', handleActiveSpeakerChange);
|
||||
}, [callObject]);
|
||||
|
||||
return (
|
||||
<ParticipantsContext.Provider
|
||||
value={{
|
||||
activeParticipant,
|
||||
allParticipants,
|
||||
currentSpeaker,
|
||||
localParticipant,
|
||||
participantCount,
|
||||
participantMarkedForRemoval,
|
||||
participants,
|
||||
screens,
|
||||
setParticipantMarkedForRemoval,
|
||||
setUsername,
|
||||
swapParticipantPosition,
|
||||
username,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ParticipantsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ParticipantsProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const useParticipants = () => useContext(ParticipantsContext);
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useCallState } from './CallProvider';
|
||||
import {
|
||||
initialTracksState,
|
||||
REMOVE_TRACKS,
|
||||
TRACK_STARTED,
|
||||
TRACK_STOPPED,
|
||||
UPDATE_TRACKS,
|
||||
tracksReducer,
|
||||
} from './tracksState';
|
||||
|
||||
const TracksContext = createContext(null);
|
||||
|
||||
export const TracksProvider = ({ children }) => {
|
||||
const { callObject } = useCallState();
|
||||
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
|
||||
const handleTrackStarted = ({ participant, track }) => {
|
||||
dispatch({
|
||||
type: TRACK_STARTED,
|
||||
participant,
|
||||
track,
|
||||
});
|
||||
};
|
||||
const handleTrackStopped = ({ participant, track }) => {
|
||||
if (participant) {
|
||||
dispatch({
|
||||
type: TRACK_STOPPED,
|
||||
participant,
|
||||
track,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleParticipantLeft = ({ participant }) => {
|
||||
dispatch({
|
||||
type: REMOVE_TRACKS,
|
||||
participant,
|
||||
});
|
||||
};
|
||||
const handleParticipantUpdated = ({ participant }) => {
|
||||
dispatch({
|
||||
type: UPDATE_TRACKS,
|
||||
participant,
|
||||
});
|
||||
};
|
||||
|
||||
const joinedSubscriptionQueue = [];
|
||||
|
||||
const handleParticipantJoined = ({ participant }) => {
|
||||
joinedSubscriptionQueue.push(participant.session_id);
|
||||
};
|
||||
|
||||
const batchInterval = setInterval(() => {
|
||||
if (!joinedSubscriptionQueue.length) return;
|
||||
const ids = joinedSubscriptionQueue.splice(0);
|
||||
const participants = callObject.participants();
|
||||
const updates = ids.reduce((o, id) => {
|
||||
const { subscribed } = participants?.[id]?.tracks?.audio;
|
||||
if (!subscribed) {
|
||||
o[id] = {
|
||||
setSubscribedTracks: {
|
||||
audio: true,
|
||||
screenAudio: true,
|
||||
screenVideo: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return o;
|
||||
}, {});
|
||||
callObject.updateParticipants(updates);
|
||||
}, 100);
|
||||
|
||||
callObject.on('track-started', handleTrackStarted);
|
||||
callObject.on('track-stopped', handleTrackStopped);
|
||||
callObject.on('participant-joined', handleParticipantJoined);
|
||||
callObject.on('participant-left', handleParticipantLeft);
|
||||
callObject.on('participant-updated', handleParticipantUpdated);
|
||||
return () => {
|
||||
clearInterval(batchInterval);
|
||||
callObject.off('track-started', handleTrackStarted);
|
||||
callObject.off('track-stopped', handleTrackStopped);
|
||||
callObject.off('participant-joined', handleParticipantJoined);
|
||||
callObject.off('participant-left', handleParticipantLeft);
|
||||
callObject.off('participant-updated', handleParticipantUpdated);
|
||||
};
|
||||
}, [callObject]);
|
||||
|
||||
const pauseVideoTrack = useCallback(
|
||||
(id) => {
|
||||
if (!callObject) return;
|
||||
/**
|
||||
* Ignore undefined, local or screenshare.
|
||||
*/
|
||||
if (!id || id.includes('local') || id.includes('screen')) return;
|
||||
// eslint-disable-next-line
|
||||
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
|
||||
if (!consumer) return;
|
||||
// eslint-disable-next-line
|
||||
rtcpeers.soup?.pauseConsumer(consumer);
|
||||
},
|
||||
[callObject]
|
||||
);
|
||||
|
||||
const resumeVideoTrack = useCallback(
|
||||
(id) => {
|
||||
/**
|
||||
* Ignore undefined, local or screenshare.
|
||||
*/
|
||||
if (!id || id.includes('local') || id.includes('screen')) return;
|
||||
|
||||
const videoTrack = callObject.participants()?.[id]?.tracks?.video;
|
||||
if (!videoTrack?.subscribed) {
|
||||
callObject.updateParticipant(id, {
|
||||
setSubscribedTracks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
|
||||
if (!consumer) return;
|
||||
// eslint-disable-next-line
|
||||
rtcpeers.soup?.resumeConsumer(consumer);
|
||||
},
|
||||
[callObject]
|
||||
);
|
||||
|
||||
return (
|
||||
<TracksContext.Provider
|
||||
value={{
|
||||
audioTracks: state.audioTracks,
|
||||
pauseVideoTrack,
|
||||
resumeVideoTrack,
|
||||
videoTracks: state.videoTracks,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TracksContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
TracksProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const useTracks = () => useContext(TracksContext);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React, { createContext, useContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const UIStateContext = createContext();
|
||||
|
||||
export const UIStateProvider = ({ children }) => {
|
||||
const [showDeviceModal, setShowDeviceModal] = useState(false);
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider
|
||||
value={{
|
||||
showDeviceModal,
|
||||
setShowDeviceModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
UIStateProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export const useUIState = () => useContext(UIStateContext);
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import equal from 'fast-deep-equal';
|
||||
|
||||
/**
|
||||
* Call State
|
||||
* ---
|
||||
* Duck file that keeps state of call participants
|
||||
*/
|
||||
|
||||
export const ACTION_PARTICIPANT_JOINED = 'ACTION_PARTICIPANT_JOINED';
|
||||
export const ACTION_PARTICIPANT_UPDATED = 'ACTION_PARTICIPANT_UPDATED';
|
||||
export const ACTION_PARTICIPANTED_LEFT = 'ACTION_PARTICIPANT_LEFT';
|
||||
|
||||
export const initialCallState = {
|
||||
audioTracks: {},
|
||||
videoTracks: {},
|
||||
callItems: {},
|
||||
fatalError: false,
|
||||
};
|
||||
|
||||
function getId(participant) {
|
||||
return participant.local ? 'local' : participant.user_id;
|
||||
}
|
||||
|
||||
export function isLocal(id) {
|
||||
return id === 'local';
|
||||
}
|
||||
|
||||
function getCallItems(newParticipants, prevCallItems) {
|
||||
const callItems = {};
|
||||
const entries = Object.entries(newParticipants);
|
||||
entries.forEach(([id, participant]) => {
|
||||
const prevState = prevCallItems[id];
|
||||
const hasLoaded = !prevState?.isLoading;
|
||||
const missingTracks = !(participant.audioTrack || participant.videoTrack);
|
||||
const joined = prevState?.joined || new Date().getTime() / 1000;
|
||||
const local = isLocal(id);
|
||||
|
||||
callItems[id] = {
|
||||
id,
|
||||
name: participant.user_name || 'Guest',
|
||||
audioTrack: participant.audioTrack,
|
||||
videoTrack: participant.videoTrack,
|
||||
hasNameSet: !!participant.user_name,
|
||||
isActiveSpeaker: !!prevState?.isActiveSpeaker,
|
||||
isCamMuted: !participant.video,
|
||||
isLoading: !hasLoaded && missingTracks,
|
||||
isLocal: local,
|
||||
isMicMuted: !participant.audio,
|
||||
isOwner: !!participant.owner,
|
||||
isRecording: !!participant.record,
|
||||
lastActiveDate: prevState?.lastActiveDate ?? null,
|
||||
mutedByHost: participant?.tracks?.audio?.off?.byRemoteRequest,
|
||||
isScreenshare: false,
|
||||
joined,
|
||||
};
|
||||
|
||||
if (participant.screenVideoTrack || participant.screenAudioTrack) {
|
||||
callItems[`${id}-screen`] = {
|
||||
audioTrack: participant.tracks.screenAudio.persistentTrack,
|
||||
hasNameSet: null,
|
||||
id: `${id}-screen`,
|
||||
isLoading: false,
|
||||
isLocal: local,
|
||||
isScreenshare: true,
|
||||
lastActiveDate: prevState?.lastActiveDate ?? null,
|
||||
name: participant.user_name,
|
||||
videoTrack: participant.screenVideoTrack,
|
||||
};
|
||||
}
|
||||
});
|
||||
return callItems;
|
||||
}
|
||||
|
||||
export function isScreenShare(id) {
|
||||
return id.endsWith('-screen');
|
||||
}
|
||||
|
||||
export function containsScreenShare(participants) {
|
||||
return Object.keys(participants).some((id) => isScreenShare(id));
|
||||
}
|
||||
|
||||
export function callReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_PARTICIPANT_UPDATED:
|
||||
return {
|
||||
...state,
|
||||
callItems: getCallItems(action.participants, state.callItems),
|
||||
};
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* 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 {
|
||||
DEVICE_STATE_OFF,
|
||||
DEVICE_STATE_BLOCKED,
|
||||
DEVICE_STATE_LOADING,
|
||||
} from './useDevices';
|
||||
|
||||
const initialParticipantsState = {
|
||||
participants: {
|
||||
local: {
|
||||
camMutedByHost: false,
|
||||
hasNameSet: false,
|
||||
id: 'local',
|
||||
isActiveSpeaker: false,
|
||||
isCamMuted: false,
|
||||
isLoading: true,
|
||||
isLocal: true,
|
||||
isMicMuted: false,
|
||||
isOwner: false,
|
||||
isRecording: false,
|
||||
isScreenshare: false,
|
||||
lastActiveDate: null,
|
||||
micMutedByHost: false,
|
||||
name: '',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// --- Derived data ---
|
||||
|
||||
function getId(participant) {
|
||||
return participant.local ? 'local' : participant.user_id;
|
||||
}
|
||||
|
||||
function getScreenId(id) {
|
||||
return `${id}-screen`;
|
||||
}
|
||||
|
||||
// ---Helpers ---
|
||||
|
||||
function getMaxPosition(participants) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.max(...Object.values(participants).map(({ position }) => position))
|
||||
);
|
||||
}
|
||||
|
||||
function getUpdatedParticipant(participant, participants) {
|
||||
const id = getId(participant);
|
||||
const prevItem = participants[id];
|
||||
|
||||
const { local } = participant;
|
||||
const { audio, video } = participant.tracks;
|
||||
|
||||
return {
|
||||
...prevItem,
|
||||
camMutedByHost: video?.off?.byRemoteRequest,
|
||||
hasNameSet: !!participant.user_name,
|
||||
id,
|
||||
isCamMuted:
|
||||
video?.state === DEVICE_STATE_OFF ||
|
||||
video?.state === DEVICE_STATE_BLOCKED,
|
||||
isLoading:
|
||||
audio?.state === DEVICE_STATE_LOADING ||
|
||||
video?.state === DEVICE_STATE_LOADING,
|
||||
isLocal: local,
|
||||
isMicMuted:
|
||||
audio?.state === DEVICE_STATE_OFF ||
|
||||
audio?.state === DEVICE_STATE_BLOCKED,
|
||||
isOwner: !!participant.owner,
|
||||
isRecording: !!participant.record,
|
||||
micMutedByHost: audio?.off?.byRemoteRequest,
|
||||
name: participant.user_name,
|
||||
};
|
||||
}
|
||||
|
||||
function getNewParticipant(participant, participants) {
|
||||
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 === 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,
|
||||
position: local ? 0 : getMaxPosition(participants) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getScreenItem(participant, participants) {
|
||||
const id = getId(participant);
|
||||
return {
|
||||
hasNameSet: null,
|
||||
id: getScreenId(id),
|
||||
isLoading: false,
|
||||
isLocal: participant.local,
|
||||
isScreenshare: true,
|
||||
lastActiveDate: null,
|
||||
name: participant.user_name,
|
||||
position: getMaxPosition(participants) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
case ACTIVE_SPEAKER: {
|
||||
const { participants, ...state } = prevState;
|
||||
if (!action.id) return prevState;
|
||||
return {
|
||||
...state,
|
||||
participants: Object.keys(participants).reduce(
|
||||
(items, id) => ({
|
||||
...items,
|
||||
[id]: {
|
||||
...participants[id],
|
||||
isActiveSpeaker: id === action.id,
|
||||
lastActiveDate:
|
||||
id === action.id
|
||||
? new Date()
|
||||
: participants[id]?.lastActiveDate,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
}
|
||||
case PARTICIPANT_JOINED: {
|
||||
const item = getNewParticipant(
|
||||
action.participant,
|
||||
prevState.participants
|
||||
);
|
||||
const { id } = item;
|
||||
const screenId = getScreenId(id);
|
||||
|
||||
const newParticipants = {
|
||||
...prevState.participants,
|
||||
[id]: item,
|
||||
};
|
||||
|
||||
// Participant is sharing screen
|
||||
if (action.participant.screen) {
|
||||
newParticipants[screenId] = getScreenItem(
|
||||
action.participant,
|
||||
newParticipants
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
participants: newParticipants,
|
||||
};
|
||||
}
|
||||
case PARTICIPANT_UPDATED: {
|
||||
const item = getUpdatedParticipant(
|
||||
action.participant,
|
||||
prevState.participants
|
||||
);
|
||||
const { id } = item;
|
||||
const screenId = getScreenId(id);
|
||||
|
||||
const newParticipants = {
|
||||
...prevState.participants,
|
||||
};
|
||||
newParticipants[id] = item;
|
||||
|
||||
if (action.participant.screen) {
|
||||
newParticipants[screenId] = getScreenItem(
|
||||
action.participant,
|
||||
newParticipants
|
||||
);
|
||||
} else {
|
||||
delete newParticipants[screenId];
|
||||
}
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
participants: newParticipants,
|
||||
};
|
||||
}
|
||||
case PARTICIPANT_LEFT: {
|
||||
const id = getId(action.participant);
|
||||
const screenId = getScreenId(id);
|
||||
const { ...participants } = prevState.participants;
|
||||
delete participants[id];
|
||||
delete participants[screenId];
|
||||
return {
|
||||
...prevState,
|
||||
participants,
|
||||
};
|
||||
}
|
||||
case SWAP_POSITION: {
|
||||
const { participants, ...state } = prevState;
|
||||
if (!action.id1 || !action.id2) return prevState;
|
||||
const pos1 = participants[action.id1]?.position;
|
||||
const pos2 = participants[action.id2]?.position;
|
||||
if (!pos1 || !pos2) return prevState;
|
||||
return {
|
||||
...state,
|
||||
participants: Object.keys(participants).reduce((items, id) => {
|
||||
let { position } = participants[id];
|
||||
if (action.id1 === id) {
|
||||
position = pos2;
|
||||
}
|
||||
if (action.id2 === id) {
|
||||
position = pos1;
|
||||
}
|
||||
return {
|
||||
...items,
|
||||
[id]: {
|
||||
...participants[id],
|
||||
position,
|
||||
},
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ACTIVE_SPEAKER,
|
||||
getId,
|
||||
getScreenId,
|
||||
initialParticipantsState,
|
||||
PARTICIPANT_JOINED,
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_UPDATED,
|
||||
participantsReducer,
|
||||
SWAP_POSITION,
|
||||
};
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { getId, getScreenId } from './participantsState';
|
||||
|
||||
const initialTracksState = {
|
||||
audioTracks: {},
|
||||
videoTracks: {},
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const TRACK_STARTED = 'TRACK_STARTED';
|
||||
const TRACK_STOPPED = 'TRACK_STOPPED';
|
||||
const REMOVE_TRACKS = 'REMOVE_TRACKS';
|
||||
const UPDATE_TRACKS = 'UPDATE_TRACKS';
|
||||
|
||||
// --- Reducer and helpers --
|
||||
|
||||
function tracksReducer(prevState, action) {
|
||||
switch (action.type) {
|
||||
case TRACK_STARTED:
|
||||
case TRACK_STOPPED: {
|
||||
const id = action.participant ? getId(action.participant) : null;
|
||||
const screenId = action.participant ? getScreenId(id) : null;
|
||||
|
||||
if (action.track.kind === 'audio' && !action.participant?.local) {
|
||||
// Ignore local audio from mic and screen share
|
||||
const newAudioTracks = {
|
||||
[id]: action.participant.tracks.audio,
|
||||
};
|
||||
if (action.participant.screen) {
|
||||
newAudioTracks[screenId] = action.participant.tracks.screenAudio;
|
||||
}
|
||||
return {
|
||||
...prevState,
|
||||
audioTracks: {
|
||||
...prevState.audioTracks,
|
||||
...newAudioTracks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const newVideoTracks = {
|
||||
[id]: action.participant.tracks.video,
|
||||
};
|
||||
if (action.participant.screen) {
|
||||
newVideoTracks[screenId] = action.participant.tracks.screenVideo;
|
||||
}
|
||||
return {
|
||||
...prevState,
|
||||
videoTracks: {
|
||||
...prevState.videoTracks,
|
||||
...newVideoTracks,
|
||||
},
|
||||
};
|
||||
}
|
||||
case REMOVE_TRACKS: {
|
||||
const { audioTracks, videoTracks } = prevState;
|
||||
const id = getId(action.participant);
|
||||
const screenId = getScreenId(id);
|
||||
|
||||
delete audioTracks[id];
|
||||
delete audioTracks[screenId];
|
||||
delete videoTracks[id];
|
||||
delete videoTracks[screenId];
|
||||
|
||||
return {
|
||||
audioTracks,
|
||||
videoTracks,
|
||||
};
|
||||
}
|
||||
case UPDATE_TRACKS: {
|
||||
const { audioTracks, videoTracks } = prevState;
|
||||
const id = getId(action.participant);
|
||||
const screenId = getScreenId(id);
|
||||
|
||||
const newAudioTracks = {
|
||||
...audioTracks,
|
||||
};
|
||||
const newVideoTracks = {
|
||||
...videoTracks,
|
||||
[id]: action.participant.tracks.video,
|
||||
};
|
||||
if (!action.participant.local) {
|
||||
newAudioTracks[id] = action.participant.tracks.audio;
|
||||
}
|
||||
if (action.participant.screen) {
|
||||
newVideoTracks[screenId] = action.participant.tracks.screenVideo;
|
||||
if (!action.participant.local) {
|
||||
newAudioTracks[screenId] = action.participant.tracks.screenAudio;
|
||||
}
|
||||
} else {
|
||||
delete newAudioTracks[screenId];
|
||||
delete newVideoTracks[screenId];
|
||||
}
|
||||
|
||||
return {
|
||||
audioTracks: newAudioTracks,
|
||||
videoTracks: newVideoTracks,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
initialTracksState,
|
||||
REMOVE_TRACKS,
|
||||
TRACK_STARTED,
|
||||
TRACK_STOPPED,
|
||||
UPDATE_TRACKS,
|
||||
tracksReducer,
|
||||
};
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* Call Machine hook
|
||||
* --
|
||||
* Manages the overaching state of a Daily call, including
|
||||
* error handling, preAuth, joining, leaving etc.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import DailyIframe from '@daily-co/daily-js';
|
||||
|
||||
import {
|
||||
ACCESS_STATE_LOBBY,
|
||||
ACCESS_STATE_NONE,
|
||||
ACCESS_STATE_UNKNOWN,
|
||||
MEETING_STATE_JOINED,
|
||||
} from '../constants';
|
||||
|
||||
export const CALL_STATE_READY = 'ready';
|
||||
export const CALL_STATE_LOBBY = 'lobby';
|
||||
export const CALL_STATE_JOINING = 'joining';
|
||||
export const CALL_STATE_JOINED = 'joined';
|
||||
export const CALL_STATE_ENDED = 'ended';
|
||||
export const CALL_STATE_ERROR = 'error';
|
||||
export const CALL_STATE_FULL = 'full';
|
||||
export const CALL_STATE_EXPIRED = 'expired';
|
||||
export const CALL_STATE_NOT_BEFORE = 'nbf';
|
||||
export const CALL_STATE_REMOVED = 'removed-from-call';
|
||||
export const CALL_STATE_REDIRECTING = 'redirecting';
|
||||
export const CALL_STATE_NOT_FOUND = 'not-found';
|
||||
export const CALL_STATE_NOT_ALLOWED = 'not-allowed';
|
||||
export const CALL_STATE_AWAITING_ARGS = 'awaiting-args';
|
||||
|
||||
export const useCallMachine = ({
|
||||
domain,
|
||||
room,
|
||||
token,
|
||||
subscribeToTracksAutomatically = true,
|
||||
}) => {
|
||||
const [daily, setDaily] = useState(null);
|
||||
const [state, setState] = useState(CALL_STATE_READY);
|
||||
const [redirectOnLeave, setRedirectOnLeave] = useState(true);
|
||||
|
||||
const url = useMemo(
|
||||
() => (domain && room ? `https://${domain}.daily.co/${room}` : null),
|
||||
[domain, room]
|
||||
);
|
||||
|
||||
/**
|
||||
* Check whether we show the lobby screen, need to knock or
|
||||
* can head straight to the call. These parameters are set using
|
||||
* `enable_knocking` and `enable_prejoin_ui` when creating the room
|
||||
* @param co – Daily call object
|
||||
*/
|
||||
const prejoinUIEnabled = async (co) => {
|
||||
const dailyRoomInfo = await co.room();
|
||||
|
||||
const prejoinEnabled =
|
||||
dailyRoomInfo?.config?.enable_prejoin_ui === null
|
||||
? !!dailyRoomInfo?.domainConfig?.enable_prejoin_ui
|
||||
: !!dailyRoomInfo?.config?.enable_prejoin_ui;
|
||||
|
||||
const knockingEnabled = !!dailyRoomInfo?.config?.enable_knocking;
|
||||
|
||||
return prejoinEnabled || knockingEnabled;
|
||||
};
|
||||
|
||||
// --- Callbacks ---
|
||||
|
||||
/**
|
||||
* Joins call (with the token, if applicable)
|
||||
*/
|
||||
const join = useCallback(
|
||||
async (callObject) => {
|
||||
setState(CALL_STATE_JOINING);
|
||||
await callObject.join({ subscribeToTracksAutomatically, token, url });
|
||||
setState(CALL_STATE_JOINED);
|
||||
},
|
||||
[token, subscribeToTracksAutomatically, url]
|
||||
);
|
||||
|
||||
/**
|
||||
* PreAuth checks whether we have access or need to knock
|
||||
*/
|
||||
const preAuth = useCallback(
|
||||
async (co) => {
|
||||
const { access } = await co.preAuth({
|
||||
subscribeToTracksAutomatically,
|
||||
token,
|
||||
url,
|
||||
});
|
||||
|
||||
// Private room and no `token` was passed
|
||||
if (
|
||||
access === ACCESS_STATE_UNKNOWN ||
|
||||
access?.level === ACCESS_STATE_NONE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Either `enable_knocking_ui` or `enable_prejoin_ui` is set to `true`
|
||||
if (
|
||||
access?.level === ACCESS_STATE_LOBBY ||
|
||||
(await prejoinUIEnabled(co))
|
||||
) {
|
||||
setState(CALL_STATE_LOBBY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Public room or private room with passed `token` and `enable_prejoin_ui` is `false`
|
||||
join(co);
|
||||
},
|
||||
[join, subscribeToTracksAutomatically, token, url]
|
||||
);
|
||||
|
||||
/**
|
||||
* Leave call
|
||||
*/
|
||||
const leave = useCallback(() => {
|
||||
if (!daily) return;
|
||||
// If we're in the error state, we've already "left", so just clean up
|
||||
if (state === CALL_STATE_ERROR) {
|
||||
daily.destroy();
|
||||
} else {
|
||||
daily.leave();
|
||||
}
|
||||
}, [daily, state]);
|
||||
|
||||
/**
|
||||
* Listen for access state updates
|
||||
*/
|
||||
const handleAccessStateUpdated = useCallback(
|
||||
async ({ access }) => {
|
||||
console.log(`🔑 Access level: ${access?.level}`);
|
||||
|
||||
/**
|
||||
* Ignore initial access-state-updated event
|
||||
*/
|
||||
if (
|
||||
[CALL_STATE_ENDED, CALL_STATE_AWAITING_ARGS, CALL_STATE_READY].includes(
|
||||
state
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
access === ACCESS_STATE_UNKNOWN ||
|
||||
access?.level === ACCESS_STATE_NONE
|
||||
) {
|
||||
setState(CALL_STATE_NOT_ALLOWED);
|
||||
return;
|
||||
}
|
||||
|
||||
const meetingState = daily.meetingState();
|
||||
if (
|
||||
access?.level === ACCESS_STATE_LOBBY &&
|
||||
meetingState === MEETING_STATE_JOINED
|
||||
) {
|
||||
// Already joined, no need to call join(daily) again.
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'full' access, we can now join the meeting.
|
||||
*/
|
||||
join(daily);
|
||||
},
|
||||
[daily, state, join]
|
||||
);
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
/**
|
||||
* Instantiate the call object and preauthenticate
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (daily || !url || state !== CALL_STATE_READY) return;
|
||||
|
||||
console.log('🚀 Creating call object');
|
||||
|
||||
const co = DailyIframe.createCallObject({
|
||||
url,
|
||||
dailyConfig: {
|
||||
experimentalChromeVideoMuteLightOff: true,
|
||||
useDevicePreferenceCookies: true,
|
||||
},
|
||||
});
|
||||
|
||||
setDaily(co);
|
||||
preAuth(co);
|
||||
}, [daily, url, state, preAuth]);
|
||||
|
||||
/**
|
||||
* Listen for changes in the participant's access state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!daily) return false;
|
||||
|
||||
daily.on('access-state-updated', handleAccessStateUpdated);
|
||||
return () => daily.off('access-state-updated', handleAccessStateUpdated);
|
||||
}, [daily, handleAccessStateUpdated]);
|
||||
|
||||
/**
|
||||
* Listen for and manage call state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!daily) return false;
|
||||
|
||||
const events = [
|
||||
'joined-meeting',
|
||||
'joining-meeting',
|
||||
'left-meeting',
|
||||
'error',
|
||||
];
|
||||
|
||||
const handleMeetingState = async (ev) => {
|
||||
const { access } = daily.accessState();
|
||||
|
||||
switch (ev.action) {
|
||||
/**
|
||||
* Don't transition to 'joining' or 'joined' UI as long as access is not 'full'.
|
||||
* This means a request to join a private room is not granted, yet.
|
||||
* Technically in requesting for access, the participant is already known
|
||||
* to the room, but not joined, yet.
|
||||
*/
|
||||
case 'joining-meeting':
|
||||
if (
|
||||
access === ACCESS_STATE_UNKNOWN ||
|
||||
access.level === ACCESS_STATE_NONE ||
|
||||
access.level === ACCESS_STATE_LOBBY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState(CALL_STATE_JOINING);
|
||||
break;
|
||||
case 'joined-meeting':
|
||||
if (
|
||||
access === ACCESS_STATE_UNKNOWN ||
|
||||
access.level === ACCESS_STATE_NONE ||
|
||||
access.level === ACCESS_STATE_LOBBY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setState(CALL_STATE_JOINED);
|
||||
break;
|
||||
case 'left-meeting':
|
||||
daily.destroy();
|
||||
if (!redirectOnLeave) return;
|
||||
setState(CALL_STATE_REDIRECTING);
|
||||
break;
|
||||
case 'error':
|
||||
switch (ev?.error?.type) {
|
||||
case 'nbf-room':
|
||||
case 'nbf-token':
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_NOT_BEFORE);
|
||||
break;
|
||||
case 'exp-room':
|
||||
case 'exp-token':
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_EXPIRED);
|
||||
break;
|
||||
case 'ejected':
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_REMOVED);
|
||||
break;
|
||||
default:
|
||||
switch (ev?.errorMsg) {
|
||||
case 'Join request rejected':
|
||||
// Join request to a private room was denied. We can end here.
|
||||
setState(CALL_STATE_LOBBY);
|
||||
daily.leave();
|
||||
break;
|
||||
case 'Meeting has ended':
|
||||
// Meeting has ended or participant was removed by an owner.
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_ENDED);
|
||||
break;
|
||||
case 'Meeting is full':
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_FULL);
|
||||
break;
|
||||
case "The meeting you're trying to join does not exist.":
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_NOT_FOUND);
|
||||
break;
|
||||
case 'You are not allowed to join this meeting':
|
||||
daily.destroy();
|
||||
setState(CALL_STATE_NOT_ALLOWED);
|
||||
break;
|
||||
default:
|
||||
setState(CALL_STATE_ERROR);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for changes in state
|
||||
events.forEach((event) => daily.on(event, handleMeetingState));
|
||||
|
||||
// Stop listening for changes in state
|
||||
return () =>
|
||||
events.forEach((event) => daily.off(event, handleMeetingState));
|
||||
}, [daily, domain, room, redirectOnLeave]);
|
||||
|
||||
return {
|
||||
daily,
|
||||
leave,
|
||||
setRedirectOnLeave,
|
||||
state: useMemo(() => state, [state]),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { sortByKey } from '../lib/sortByKey';
|
||||
|
||||
export const DEVICE_STATE_LOADING = 'loading';
|
||||
export const DEVICE_STATE_PENDING = 'pending';
|
||||
export const DEVICE_STATE_ERROR = 'error';
|
||||
export const DEVICE_STATE_GRANTED = 'granted';
|
||||
export const DEVICE_STATE_NOT_FOUND = 'not-found';
|
||||
export const DEVICE_STATE_NOT_SUPPORTED = 'not-supported';
|
||||
export const DEVICE_STATE_BLOCKED = 'blocked';
|
||||
export const DEVICE_STATE_IN_USE = 'in-use';
|
||||
export const DEVICE_STATE_OFF = 'off';
|
||||
export const DEVICE_STATE_PLAYABLE = 'playable';
|
||||
export const DEVICE_STATE_SENDABLE = 'sendable';
|
||||
|
||||
export const useDevices = (callObject) => {
|
||||
const [deviceState, setDeviceState] = useState(DEVICE_STATE_LOADING);
|
||||
const [currentDevices, setCurrentDevices] = useState(null);
|
||||
|
||||
const [cams, setCams] = useState([]);
|
||||
const [mics, setMics] = useState([]);
|
||||
const [speakers, setSpeakers] = useState([]);
|
||||
|
||||
const [camError, setCamError] = useState(null);
|
||||
const [micError, setMicError] = useState(null);
|
||||
|
||||
const updateDeviceState = useCallback(async () => {
|
||||
if (
|
||||
typeof navigator?.mediaDevices?.getUserMedia === 'undefined' ||
|
||||
typeof navigator?.mediaDevices?.enumerateDevices === 'undefined'
|
||||
) {
|
||||
setDeviceState(DEVICE_STATE_NOT_SUPPORTED);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { devices } = await callObject.enumerateDevices();
|
||||
|
||||
const { camera, mic, speaker } = await callObject.getInputDevices();
|
||||
|
||||
const [defaultCam, ...videoDevices] = devices.filter(
|
||||
(d) => d.kind === 'videoinput' && d.deviceId !== ''
|
||||
);
|
||||
setCams(
|
||||
[
|
||||
defaultCam,
|
||||
...videoDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
|
||||
].filter(Boolean)
|
||||
);
|
||||
const [defaultMic, ...micDevices] = devices.filter(
|
||||
(d) => d.kind === 'audioinput' && d.deviceId !== ''
|
||||
);
|
||||
setMics(
|
||||
[
|
||||
defaultMic,
|
||||
...micDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
|
||||
].filter(Boolean)
|
||||
);
|
||||
const [defaultSpeaker, ...speakerDevices] = devices.filter(
|
||||
(d) => d.kind === 'audiooutput' && d.deviceId !== ''
|
||||
);
|
||||
setSpeakers(
|
||||
[
|
||||
defaultSpeaker,
|
||||
...speakerDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
setCurrentDevices({
|
||||
camera,
|
||||
mic,
|
||||
speaker,
|
||||
});
|
||||
|
||||
console.log(`Current cam: ${camera.label}`);
|
||||
console.log(`Current mic: ${mic.label}`);
|
||||
console.log(`Current speakers: ${speaker.label}`);
|
||||
} catch (e) {
|
||||
setDeviceState(DEVICE_STATE_NOT_SUPPORTED);
|
||||
}
|
||||
}, [callObject]);
|
||||
|
||||
const updateDeviceErrors = useCallback(() => {
|
||||
if (!callObject) return;
|
||||
const { tracks } = callObject.participants().local;
|
||||
|
||||
if (tracks.video?.blocked?.byPermissions) {
|
||||
setCamError(DEVICE_STATE_BLOCKED);
|
||||
} else if (tracks.video?.blocked?.byDeviceMissing) {
|
||||
setCamError(DEVICE_STATE_NOT_FOUND);
|
||||
} else if (tracks.video?.blocked?.byDeviceInUse) {
|
||||
setCamError(DEVICE_STATE_IN_USE);
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
DEVICE_STATE_LOADING,
|
||||
DEVICE_STATE_OFF,
|
||||
DEVICE_STATE_PLAYABLE,
|
||||
DEVICE_STATE_SENDABLE,
|
||||
].includes(tracks.video.state)
|
||||
) {
|
||||
setCamError(null);
|
||||
}
|
||||
|
||||
if (tracks.audio?.blocked?.byPermissions) {
|
||||
setMicError(DEVICE_STATE_BLOCKED);
|
||||
} else if (tracks.audio?.blocked?.byDeviceMissing) {
|
||||
setMicError(DEVICE_STATE_NOT_FOUND);
|
||||
} else if (tracks.audio?.blocked?.byDeviceInUse) {
|
||||
setMicError(DEVICE_STATE_IN_USE);
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
DEVICE_STATE_LOADING,
|
||||
DEVICE_STATE_OFF,
|
||||
DEVICE_STATE_PLAYABLE,
|
||||
DEVICE_STATE_SENDABLE,
|
||||
].includes(tracks.audio.state)
|
||||
) {
|
||||
setMicError(null);
|
||||
}
|
||||
}, [callObject]);
|
||||
|
||||
const handleParticipantUpdated = useCallback(
|
||||
({ participant }) => {
|
||||
if (!callObject || !participant.local) return;
|
||||
|
||||
setDeviceState((prevState) => {
|
||||
if (prevState === DEVICE_STATE_NOT_SUPPORTED) return prevState;
|
||||
switch (participant?.tracks.video.state) {
|
||||
case DEVICE_STATE_BLOCKED:
|
||||
updateDeviceErrors();
|
||||
return DEVICE_STATE_ERROR;
|
||||
case DEVICE_STATE_OFF:
|
||||
case DEVICE_STATE_PLAYABLE:
|
||||
if (prevState === DEVICE_STATE_GRANTED) {
|
||||
return prevState;
|
||||
}
|
||||
updateDeviceState();
|
||||
return DEVICE_STATE_GRANTED;
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
});
|
||||
},
|
||||
[callObject, updateDeviceState, updateDeviceErrors]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
|
||||
/**
|
||||
If the user is slow to allow access, we'll update the device state
|
||||
so our app can show a prompt requesting access
|
||||
*/
|
||||
let pendingAccessTimeout;
|
||||
|
||||
const handleJoiningMeeting = () => {
|
||||
pendingAccessTimeout = setTimeout(() => {
|
||||
setDeviceState(DEVICE_STATE_PENDING);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleJoinedMeeting = () => {
|
||||
clearTimeout(pendingAccessTimeout);
|
||||
// Note: setOutputDevice() is not honored before join() so we must enumerate again
|
||||
updateDeviceState();
|
||||
};
|
||||
|
||||
callObject.on('joining-meeting', handleJoiningMeeting);
|
||||
callObject.on('joined-meeting', handleJoinedMeeting);
|
||||
callObject.on('participant-updated', handleParticipantUpdated);
|
||||
return () => {
|
||||
console.log('UNMOUNT');
|
||||
clearTimeout(pendingAccessTimeout);
|
||||
callObject.off('joining-meeting', handleJoiningMeeting);
|
||||
callObject.off('joined-meeting', handleJoinedMeeting);
|
||||
callObject.off('participant-updated', handleParticipantUpdated);
|
||||
};
|
||||
}, [callObject, handleParticipantUpdated, updateDeviceState]);
|
||||
|
||||
const setCamDevice = useCallback(
|
||||
async (newCam, useLocalStorage = true) => {
|
||||
if (!callObject || newCam.deviceId === currentDevices?.cam?.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?.speakers?.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, speakers: newSpeakers }));
|
||||
},
|
||||
[callObject, currentDevices]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callObject) return false;
|
||||
|
||||
console.log('💻 Device provider events bound');
|
||||
|
||||
const handleCameraError = ({
|
||||
errorMsg: { errorMsg, audioOk, videoOk },
|
||||
error,
|
||||
}) => {
|
||||
switch (error?.type) {
|
||||
case 'cam-in-use':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
setCamError(DEVICE_STATE_IN_USE);
|
||||
break;
|
||||
case 'mic-in-use':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
setMicError(DEVICE_STATE_IN_USE);
|
||||
break;
|
||||
case 'cam-mic-in-use':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
setCamError(DEVICE_STATE_IN_USE);
|
||||
setMicError(DEVICE_STATE_IN_USE);
|
||||
break;
|
||||
default:
|
||||
switch (errorMsg) {
|
||||
case 'devices error':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
setCamError(videoOk ? null : DEVICE_STATE_NOT_FOUND);
|
||||
setMicError(audioOk ? null : DEVICE_STATE_NOT_FOUND);
|
||||
break;
|
||||
case 'not allowed':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
updateDeviceErrors();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = ({ errorMsg }) => {
|
||||
switch (errorMsg) {
|
||||
case 'not allowed':
|
||||
setDeviceState(DEVICE_STATE_ERROR);
|
||||
updateDeviceErrors();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartedCamera = () => {
|
||||
updateDeviceErrors();
|
||||
};
|
||||
|
||||
callObject.on('camera-error', handleCameraError);
|
||||
callObject.on('error', handleError);
|
||||
callObject.on('started-camera', handleStartedCamera);
|
||||
return () => {
|
||||
callObject.off('camera-error', handleCameraError);
|
||||
callObject.off('error', handleError);
|
||||
callObject.off('started-camera', handleStartedCamera);
|
||||
};
|
||||
}, [callObject, updateDeviceErrors]);
|
||||
|
||||
return {
|
||||
cams,
|
||||
mics,
|
||||
speakers,
|
||||
camError,
|
||||
micError,
|
||||
currentDevices,
|
||||
deviceState,
|
||||
setCamDevice,
|
||||
setMicDevice,
|
||||
setSpeakersDevice,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDevices;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||
|
||||
import { useTracks } from '../contexts/TracksProvider';
|
||||
|
||||
export const useAudioTrack = (participant) => {
|
||||
const { audioTracks } = useTracks();
|
||||
|
||||
return useDeepCompareMemo(() => {
|
||||
const audioTrack = audioTracks?.[participant?.id];
|
||||
// @ts-ignore
|
||||
return audioTrack?.persistentTrack;
|
||||
}, [participant?.id, audioTracks]);
|
||||
};
|
||||
|
||||
export default useAudioTrack;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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) => {
|
||||
const { videoTracks } = useTracks();
|
||||
|
||||
return useDeepCompareMemo(() => {
|
||||
const videoTrack = videoTracks?.[participant?.id];
|
||||
if (
|
||||
videoTrack?.state === DEVICE_STATE_OFF ||
|
||||
videoTrack?.state === DEVICE_STATE_BLOCKED ||
|
||||
(!videoTrack?.subscribed &&
|
||||
participant?.id !== 'local' &&
|
||||
!participant.isScreenshare)
|
||||
)
|
||||
return null;
|
||||
return videoTrack?.track;
|
||||
}, [participant?.id, videoTracks]);
|
||||
};
|
||||
|
||||
export default useVideoTrack;
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="currentColor"><path fill="currentColor" d="M15,7H9V1c0-0.6-0.4-1-1-1S7,0.4,7,1v6H1C0.4,7,0,7.4,0,8s0.4,1,1,1h6v6c0,0.6,0.4,1,1,1s1-0.4,1-1V9h6 c0.6,0,1-0.4,1-1S15.6,7,15,7z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M16.75 32.5C12.5188 32.6114 8.30606 31.9031 4.344 30.414C4.16718 30.3354 4.01695 30.2073 3.91151 30.0451C3.80608 29.8828 3.74998 29.6935 3.75 29.5C3.75291 26.8487 4.80742 24.3069 6.68215 22.4321C8.55688 20.5574 11.0987 19.5029 13.75 19.5H19.75C22.4013 19.5029 24.9431 20.5574 26.8179 22.4321C28.6926 24.3069 29.7471 26.8487 29.75 29.5C29.75 29.6935 29.6939 29.8828 29.5885 30.0451C29.4831 30.2073 29.3328 30.3354 29.156 30.414C25.1939 31.9031 20.9812 32.6114 16.75 32.5Z" fill="currentColor"/>
|
||||
<path d="M16.75 17.5C12.171 17.5 8.75 12.749 8.75 8.5C8.75 6.37827 9.59285 4.34344 11.0931 2.84315C12.5934 1.34285 14.6283 0.5 16.75 0.5C18.8717 0.5 20.9066 1.34285 22.4069 2.84315C23.9071 4.34344 24.75 6.37827 24.75 8.5C24.75 12.749 21.329 17.5 16.75 17.5Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="32" height="32" fill="white" transform="translate(0.75 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -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="M16 8V6C16 5.46957 15.7893 4.96086 15.4142 4.58579C15.0391 4.21071 14.5304 4 14 4H3C2.46957 4 1.96086 4.21071 1.58579 4.58579C1.21071 4.96086 1 5.46957 1 6V18C1 18.5304 1.21071 19.0391 1.58579 19.4142C1.96086 19.7893 2.46957 20 3 20H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.24219 20H14.0002C14.5306 20 15.0393 19.7893 15.4144 19.4142C15.7895 19.0391 16.0002 18.5304 16.0002 18V15L23.0002 17V7L20.5392 7.7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22 2L2 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 777 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 10L23 7V17L16 14" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 4H3C1.89543 4 1 4.89543 1 6V18C1 19.1046 1.89543 20 3 20H14C15.1046 20 16 19.1046 16 18V6C16 4.89543 15.1046 4 14 4Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.64645 6.95355L8 7.30711L8.35355 6.95355L13.6536 1.65355C13.8583 1.44882 14.1417 1.44882 14.3464 1.65355C14.5512 1.85829 14.5512 2.14171 14.3464 2.34645L9.04645 7.64645L8.69289 8L9.04645 8.35355L14.3464 13.6536C14.5512 13.8583 14.5512 14.1417 14.3464 14.3464C14.3033 14.3896 14.2394 14.4307 14.1643 14.4608C14.087 14.4917 14.0252 14.5 14 14.5C13.9748 14.5 13.913 14.4917 13.8357 14.4608C13.7606 14.4307 13.6967 14.3896 13.6536 14.3464L8.35355 9.04645L8 8.69289L7.64645 9.04645L2.34645 14.3464C2.27596 14.4169 2.22535 14.4499 2.18443 14.4681C2.14568 14.4853 2.09166 14.5 2 14.5C1.90834 14.5 1.85432 14.4853 1.81557 14.4681C1.77465 14.4499 1.72404 14.4169 1.65355 14.3464C1.44882 14.1417 1.44882 13.8583 1.65355 13.6536L6.95355 8.35355L7.30711 8L6.95355 7.64645L1.65355 2.34645C1.44882 2.14171 1.44882 1.85829 1.65355 1.65355C1.85829 1.44882 2.14171 1.44882 2.34645 1.65355L7.64645 6.95355Z" fill="currentColor" stroke="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.596078 12.0341C0.877382 11.7272 1.25891 11.5548 1.65674 11.5548C2.05456 11.5548 2.43609 11.7272 2.7174 12.0341L6.60648 16.2767L7.31359 15.5054L2.01029 9.71993C1.72899 9.41305 1.57095 8.99684 1.57095 8.56285C1.57095 8.12886 1.72899 7.71264 2.01029 7.40577C2.2916 7.09889 2.67313 6.92648 3.07095 6.92648C3.46878 6.92648 3.85031 7.09889 4.13161 7.40576L9.43491 13.1912L10.142 12.4198L4.13161 5.86299C3.85031 5.55611 3.69227 5.13989 3.69227 4.7059C3.69227 4.27191 3.85031 3.8557 4.13161 3.54882C4.41292 3.24194 4.79445 3.06954 5.19227 3.06954C5.5901 3.06954 5.97163 3.24194 6.25293 3.54882L12.2633 10.1056L12.9704 9.33424L7.66715 3.54882C7.38584 3.24194 7.22781 2.82573 7.22781 2.39173C7.22781 1.95774 7.38584 1.54153 7.66715 1.23465C7.94845 0.927773 8.32998 0.755371 8.72781 0.755371C9.12563 0.755371 9.50716 0.927773 9.78847 1.23465L16.7761 8.85752C16.8966 8.98889 17.046 9.08447 17.2099 9.13521C17.3738 9.18594 17.5469 9.19014 17.7127 9.14741C17.8785 9.10467 18.0316 9.01642 18.1574 8.89103C18.2832 8.76564 18.3775 8.60727 18.4314 8.43094L19.6512 4.43437C19.7809 4.0093 20.0473 3.64983 20.4011 3.42238C20.7549 3.19494 21.1722 3.1149 21.5759 3.19707C21.9612 3.29024 22.3072 3.52046 22.5595 3.85154C22.8118 4.18261 22.9561 4.59576 22.9696 5.02603L23.3373 11.876C23.4122 13.2598 23.2176 14.6453 22.7663 15.9412C22.315 17.2372 21.6172 18.4141 20.7189 19.3947L19.3344 20.9051C17.6466 22.7463 15.3574 23.7808 12.9704 23.7808C10.5835 23.7808 8.29431 22.7463 6.60648 20.9051L0.596078 14.3483C0.314773 14.0414 0.156738 13.6252 0.156738 13.1912C0.156738 12.7572 0.314773 12.341 0.596078 12.0341Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.00024 10V6C6.9937 5.34157 7.11855 4.68845 7.36751 4.07886C7.61646 3.46928 7.98451 2.91547 8.45011 2.44987C8.91572 1.98427 9.46952 1.61622 10.0791 1.36726C10.6887 1.11831 11.3418 0.993451 12.0002 1V1C12.6587 0.993451 13.3118 1.11831 13.9214 1.36726C14.531 1.61622 15.0848 1.98427 15.5504 2.44987C16.016 2.91547 16.384 3.46928 16.633 4.07886C16.8819 4.68845 17.0068 5.34157 17.0002 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 23H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 19V23" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.0001 7V6C17.0001 4.67392 16.4733 3.40215 15.5356 2.46447C14.598 1.52678 13.3262 1 12.0001 1C10.674 1 9.40225 1.52678 8.46457 2.46447C7.52689 3.40215 7.0001 4.67392 7.0001 6V10C6.99432 10.9 7.23494 11.7843 7.6959 12.5573C8.15686 13.3303 8.82056 13.9623 9.6151 14.385" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.4839 14.758C14.258 14.526 14.9624 14.1054 15.5338 13.5339C16.1052 12.9625 16.5258 12.2581 16.7579 11.484" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.00005 10C2.99536 11.4223 3.32952 12.8254 3.97485 14.0929C4.62018 15.3604 5.55813 16.456 6.71105 17.289" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.57715 18.665C10.366 18.8855 11.1811 18.9982 12.0001 19C13.1834 19.0049 14.3559 18.7754 15.4501 18.3249C16.5442 17.8743 17.5383 17.2116 18.375 16.3749C19.2117 15.5382 19.8745 14.5441 20.325 13.4499C20.7756 12.3558 21.005 11.1833 21.0001 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 21L21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="16" height="17" viewBox="0 0 16 17" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M15.1 6.5C14.9749 6.47679 14.8463 6.47931 14.7222 6.50742C14.598 6.53553 14.4809 6.58864 14.378 6.6635C14.2751 6.73836 14.1885 6.8334 14.1235 6.94284C14.0585 7.05227 14.0165 7.17379 14 7.3C13.8011 8.58518 13.1986 9.7738 12.2797 10.694C11.3607 11.6142 10.1729 12.2183 8.888 12.419L7 15.062V16.5H9V14.4C10.7383 14.1842 12.3567 13.4008 13.6043 12.1713C14.8519 10.9418 15.6588 9.33492 15.9 7.6C15.9322 7.34919 15.8672 7.09558 15.7185 6.89108C15.5698 6.68659 15.3485 6.54668 15.1 6.5Z" fill="currentColor"/>
|
||||
<path d="M6.51985 12.293C5.38014 12.0092 4.35089 11.3922 3.56359 10.5206C2.77628 9.64905 2.26667 8.56258 2.09985 7.39998C2.08377 7.26053 2.03945 7.12582 1.96957 7.00407C1.8997 6.88232 1.80574 6.77609 1.69344 6.69186C1.58114 6.60763 1.45285 6.54718 1.3164 6.51419C1.17996 6.4812 1.03822 6.47637 0.899847 6.49998C0.651335 6.54667 0.430067 6.68658 0.281343 6.89107C0.132618 7.09557 0.0676889 7.34918 0.0998471 7.59998C0.311156 9.03515 0.909913 10.3855 1.83166 11.5057C2.7534 12.6258 3.96321 13.4733 5.33085 13.957L6.51985 12.293Z" fill="currentColor"/>
|
||||
<path d="M7.814 10.481L12 4.621V4.5C12 3.43913 11.5786 2.42172 10.8284 1.67157C10.0783 0.921427 9.06087 0.5 8 0.5C6.93913 0.5 5.92172 0.921427 5.17157 1.67157C4.42143 2.42172 4 3.43913 4 4.5V6.5C4.00376 7.52613 4.40173 8.51156 5.11161 9.25252C5.82149 9.99348 6.78898 10.4333 7.814 10.481Z" fill="currentColor"/>
|
||||
<path d="M3.00007 16.5C2.81644 16.4998 2.63639 16.4491 2.47968 16.3534C2.32297 16.2576 2.19565 16.1206 2.11167 15.9573C2.02769 15.794 1.9903 15.6107 2.0036 15.4276C2.0169 15.2444 2.08038 15.0685 2.18707 14.919L12.1871 0.918991C12.3412 0.703105 12.5747 0.557273 12.8363 0.513577C13.0979 0.469881 13.3662 0.5319 13.5821 0.685991C13.798 0.840082 13.9438 1.07362 13.9875 1.33524C14.0312 1.59685 13.9692 1.8651 13.8151 2.08099L3.81507 16.081C3.72246 16.2107 3.60017 16.3165 3.45839 16.3893C3.31662 16.4622 3.15948 16.5002 3.00007 16.5Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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="M12 15C9.2 15 7 12.8 7 10V6C7 3.2 9.2 1 12 1C14.8 1 17 3.2 17 6V10C17 12.8 14.8 15 12 15Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21 10C21 15 17 19 12 19C7 19 3 15 3 10" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 23H17" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 19V23" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 736 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="M6.5 1H17.5C20 1 22 3 22 5.5C22 8 20 10 17.5 10H6.5" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 10C8.98528 10 11 7.98528 11 5.5C11 3.01472 8.98528 1 6.5 1C4.01472 1 2 3.01472 2 5.5C2 7.98528 4.01472 10 6.5 10Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 23H6.5C4 23 2 21 2 18.5C2 16 4 14 6.5 14H17.5" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 23C19.9853 23 22 20.9853 22 18.5C22 16.0147 19.9853 14 17.5 14C15.0147 14 13 16.0147 13 18.5C13 20.9853 15.0147 23 17.5 23Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 940 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8571 4.968L12.7195 5.23065L12.884 5.47735C13.002 5.65435 13.0711 5.87317 13.1527 6.13144C13.1756 6.20391 13.1994 6.27949 13.2257 6.35811L13.3034 6.59145L13.5357 6.67225L15.5 7.35547V8.64453L13.5357 9.32775L13.2805 9.41654L13.2149 9.67873C13.1091 10.1022 13.0264 10.409 12.884 10.6227L12.7195 10.8694L12.8571 11.132L13.7865 12.9064L12.9064 13.7865L11.132 12.8571L10.9634 12.7688L10.7787 12.8149C10.5396 12.8747 10.3333 12.9631 10.1644 13.0355L10.153 13.0404C9.97401 13.1172 9.83419 13.1761 9.67873 13.2149L9.41654 13.2805L9.32775 13.5357L8.64453 15.5H7.35547L6.67225 13.5357L6.58346 13.2805L6.32127 13.2149C5.89778 13.1091 5.59098 13.0264 5.37735 12.884L5.13065 12.7195L4.868 12.8571L3.09362 13.7865L2.21348 12.9064L3.14292 11.132L3.23124 10.9634L3.18507 10.7787C3.12529 10.5396 3.03685 10.3333 2.96445 10.1644L2.95957 10.153C2.88285 9.97401 2.82394 9.83419 2.78507 9.67873L2.71952 9.41654L2.46426 9.32775L0.5 8.64453V7.35547L2.46426 6.67225L2.69656 6.59145L2.77434 6.35811C2.80055 6.27949 2.82442 6.20392 2.84731 6.13145C2.9289 5.87318 2.99802 5.65435 3.11603 5.47735L3.28049 5.23065L3.14292 4.968L2.21348 3.19362L3.19362 2.21348L4.968 3.14292L5.23065 3.28049L5.47735 3.11603C5.65435 2.99802 5.87318 2.9289 6.13145 2.84731C6.20392 2.82442 6.27949 2.80055 6.35811 2.77434L6.59145 2.69656L6.67225 2.46426L7.35547 0.5H8.63962L9.32566 2.55811L9.41245 2.8185L9.67873 2.88507C10.1022 2.99094 10.409 3.0736 10.6226 3.21603L10.8694 3.38049L11.132 3.24292L12.9064 2.31348L13.7865 3.19362L12.8571 4.968ZM4.5 8C4.5 9.97614 6.02386 11.5 8 11.5C9.97614 11.5 11.5 9.97614 11.5 8C11.5 6.02386 9.97614 4.5 8 4.5C6.02386 4.5 4.5 6.02386 4.5 8Z" fill="currentColor" stroke="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1 @@
|
|||
// Note: I am here because next-transpile-modules requires a mainfile
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export const asyncGetUserDevices = async (useLocal = true) => {
|
||||
const devices = await callObject.getInputDevices(); // navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const defaultCam = useLocal && localStorage.getItem('defaultCamId');
|
||||
const defaultMic = useLocal && localStorage.getItem('defaultMicId');
|
||||
const defaultSpeakers = useLocal && localStorage.getItem('defaultSpeakersId');
|
||||
|
||||
const cams = devices.filter((d) => d.kind === 'videoinput');
|
||||
const mics = devices.filter((d) => d.kind === 'audioinput');
|
||||
const speakers = devices.filter((d) => d.kind === 'audiooutput');
|
||||
|
||||
const defaultCamDevice = devices.filter((d) => d.deviceId === defaultCam);
|
||||
const defaultMicDevice = devices.filter((d) => d.deviceId === defaultMic);
|
||||
const defaultSpeakersDevice = devices.filter(
|
||||
(d) => d.deviceId === defaultSpeakers
|
||||
);
|
||||
|
||||
const currentCam = defaultCamDevice.length ? defaultCamDevice[0] : cams[0];
|
||||
const currentMic = defaultMicDevice.length ? defaultMicDevice[0] : mics[0];
|
||||
const currentSpeakers = defaultSpeakersDevice.length
|
||||
? defaultSpeakersDevice[0]
|
||||
: speakers[0];
|
||||
|
||||
return { cams, mics, speakers, currentCam, currentMic, currentSpeakers };
|
||||
};
|
||||
|
||||
export default asyncGetUserDevices;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export const sortByKey = (a, b, key, caseSensitive = true) => {
|
||||
const aKey =
|
||||
!caseSensitive && typeof a[key] === 'string'
|
||||
? a[key]?.toLowerCase()
|
||||
: a[key];
|
||||
const bKey =
|
||||
!caseSensitive && typeof b[key] === 'string'
|
||||
? b[key]?.toLowerCase()
|
||||
: b[key];
|
||||
if (aKey > bKey) return 1;
|
||||
if (aKey < bKey) return -1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
export default sortByKey;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const sortLastActive = (a, b) => {
|
||||
if (a?.lastActiveDate > b?.lastActiveDate) return -1;
|
||||
if (a?.lastActiveDate < b?.lastActiveDate) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
export default sortLastActive;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
export const parseJWT = (token) => {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
|
||||
.join('')
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
};
|
||||
|
||||
const keyMap = {
|
||||
ao: 'start_audio_off',
|
||||
ctoe: 'close_tab_on_exit',
|
||||
d: 'domainId',
|
||||
eje: 'eject_after_elapsed',
|
||||
ejt: 'eject_at_token_exp',
|
||||
er: 'enable_recording',
|
||||
exp: 'exp',
|
||||
iat: 'createdAt',
|
||||
nbf: 'nbf',
|
||||
o: 'isOwner',
|
||||
r: 'room',
|
||||
rome: 'redirect_on_meeting_exit',
|
||||
sr: 'start_cloud_recording',
|
||||
ss: 'enable_screenshare',
|
||||
u: 'username',
|
||||
ud: 'id',
|
||||
uil: 'lang',
|
||||
vo: 'start_video_off',
|
||||
};
|
||||
|
||||
export const parseMeetingToken = (token) => {
|
||||
const parsed = parseJWT(token);
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(parsed)) {
|
||||
result[keyMap[key]] = val;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@dailyjs/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@daily-co/daily-js": "^0.12.0",
|
||||
"classnames": "^2.3.1",
|
||||
"next-compose-plugins": "^2.2.1",
|
||||
"next-transpile-modules": "^7.0.0",
|
||||
"no-scroll": "^2.1.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"shallow-equal": "^1.2.1",
|
||||
"use-deep-compare": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
|
||||
export default class StyledDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const originalRenderPage = ctx.renderPage;
|
||||
|
||||
try {
|
||||
ctx.renderPage = () =>
|
||||
originalRenderPage({
|
||||
enhanceApp: (App) => (props) =>
|
||||
sheet.collectStyles(<App {...props} />),
|
||||
});
|
||||
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return {
|
||||
...initialProps,
|
||||
styles: (
|
||||
<>
|
||||
{initialProps.styles}
|
||||
{sheet.getStyleElement()}
|
||||
</>
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
sheet.seal();
|
||||
}
|
||||
}
|
||||
|
||||
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=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
export const defaultTheme = {
|
||||
background: '#2B3F56',
|
||||
reverse: '#FFFFFF',
|
||||
|
||||
primary: {
|
||||
default: '#1bebb9',
|
||||
dark: '#00a981',
|
||||
},
|
||||
|
||||
secondary: {
|
||||
default: '#FF9254',
|
||||
dark: '#FB651E',
|
||||
},
|
||||
|
||||
blue: {
|
||||
light: '#2B3F56',
|
||||
default: '#1F2D3D',
|
||||
dark: '#121A24',
|
||||
},
|
||||
|
||||
green: {
|
||||
light: '#EEFAE0',
|
||||
default: '#72CC18',
|
||||
dark: '#62A60F',
|
||||
},
|
||||
|
||||
red: {
|
||||
light: '#FDDDDD',
|
||||
default: '#E71115',
|
||||
dark: '#BB0C0C',
|
||||
},
|
||||
|
||||
gray: {
|
||||
wash: '#F7F9FA',
|
||||
light: '#E6EAEF',
|
||||
default: '#C8D1DC',
|
||||
dark: '#7B848F',
|
||||
},
|
||||
|
||||
text: {
|
||||
default: '#2B3F56',
|
||||
reverse: '#FFFFFF',
|
||||
mid: '#7B848F',
|
||||
darkest: '#1F2D3D',
|
||||
pre: '#FB651E',
|
||||
},
|
||||
};
|
||||
|
||||
export default defaultTheme;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export const hexa = (hex, alpha) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
|
||||
if (alpha >= 0) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
export default hexa;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "daily-web-examples",
|
||||
"version": "0.1.0",
|
||||
"repository": "git@github.com:daily-co/daily-web-examples.git",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"dailyjs/*",
|
||||
"prebuilt-ui/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.2.0"
|
||||
}
|
||||
}
|
||||