resolve conflicts

This commit is contained in:
Jon 2021-07-15 15:12:57 +01:00
commit 137afb1831
50 changed files with 1264 additions and 26 deletions

View File

@ -10,7 +10,15 @@ Send messages to other participants using sendAppMessage
### [📺 Live streaming](./live-streaming)
Broadcast call to a custom RTMP endpoint using a variety of difference layout modes
Broadcast call to a custom RTMP endpoint using a variety of different layout modes
### [⏺️ Recording](./recording)
Record a call video and audio using both cloud and local modes
### [🔥 Flying emojis](./flying-emojis)
Send emoji reactions to all clients using sendAppMessage
### [📃 Pagination](./pagination)

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useCallUI } from '@dailyjs/shared/hooks/useCallUI';
@ -8,7 +8,20 @@ import { Asides } from './Asides';
import { Modals } from './Modals';
export const App = ({ customComponentForState }) => {
const { state } = useCallState();
const { roomExp, state } = useCallState();
const [secs, setSecs] = useState();
// If room has an expiry time, we'll calculate how many seconds until expiry
useEffect(() => {
if (!roomExp) {
return false;
}
const i = setInterval(() => {
const timeLeft = Math.round((roomExp - Date.now()) / 1000);
setSecs(`${Math.floor(timeLeft / 60)}:${`0${timeLeft % 60}`.slice(-2)}`);
}, 1000);
return () => clearInterval(i);
}, [roomExp]);
const componentForState = useCallUI({
state,
@ -17,7 +30,7 @@ export const App = ({ customComponentForState }) => {
});
// Memoize children to avoid unnecassary renders from HOC
return useMemo(
const memoizedApp = useMemo(
() => (
<div className="app">
{componentForState()}
@ -38,11 +51,32 @@ export const App = ({ customComponentForState }) => {
),
[componentForState]
);
return (
<>
{roomExp && <div className="countdown">{secs}</div>} {memoizedApp}
<style jsx>{`
.countdown {
position: fixed;
top: 0px;
right: 0px;
width: 48px;
text-align: center;
padding: 4px 0;
font-size: 0.875rem;
font-weight: var(--weight-medium);
border-radius: 0 0 0 var(--radius-sm);
background: var(--blue-dark);
color: white;
z-index: 999;
}
`}</style>
</>
);
};
App.propTypes = {
asides: PropTypes.arrayOf(PropTypes.func),
customComponentsForState: PropTypes.any,
customComponentForState: PropTypes.any,
};
export default App;

View File

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardBody } from '@dailyjs/shared/components/Card';
import Loader from '@dailyjs/shared/components/Loader';
import { Well } from '@dailyjs/shared/components/Well';
import PropTypes from 'prop-types';
export const CreatingRoom = ({ onCreated }) => {
const [room, setRoom] = useState();
const [fetching, setFetching] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (room) return;
async function createRoom() {
setError(false);
setFetching(true);
console.log(`🚪 Creating new demo room...`);
// Create a room server side (using Next JS serverless)
const res = await fetch('/api/createRoom', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const resJson = await res.json();
if (resJson.name) {
setFetching(false);
setRoom(resJson.name);
return;
}
setError(resJson.error || 'An unknown error occured');
setFetching(false);
}
createRoom();
}, [room]);
useEffect(() => {
if (!room || !onCreated) return;
console.log(`🚪 Room created: ${room}, joining now`);
onCreated(room, true);
}, [room, onCreated]);
return (
<div className="creating-room">
{fetching && (
<div className="creating">
<Loader /> Creating new demo room...
</div>
)}
{error && (
<Card error>
<CardHeader>An error occured</CardHeader>
<CardBody>
<Well variant="error">{error}</Well>
An error occured when trying to create a demo room. Please check
that your environmental variables are correct and try again.
</CardBody>
</Card>
)}
<style jsx>
{`
.creating-room {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
max-width: 420px;
margin: 0 auto;
}
.creating-room .creating {
display: flex;
}
.creating-room :global(.loader) {
margin-right: var(--spacing-xxxs);
}
`}
</style>
</div>
);
};
CreatingRoom.propTypes = {
onCreated: PropTypes.func.isRequired,
};
export default CreatingRoom;

View File

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

View File

@ -0,0 +1,36 @@
export default async function handler(req, res) {
if (req.method === 'POST') {
console.log(`Creating room on domain ${process.env.DAILY_DOMAIN}`);
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
},
body: JSON.stringify({
properties: {
exp: Math.round(Date.now() / 1000) + 5 * 60, // expire in 5 minutes
eject_at_room_exp: true,
},
}),
};
const dailyRes = await fetch(
`${process.env.DAILY_REST_DOMAIN || 'https://api.daily.co/v1'}/rooms`,
options
);
const { name, url, error } = await dailyRes.json();
if (error) {
return res.status(500).json({ error });
}
return res
.status(200)
.json({ name, url, domain: process.env.DAILY_DOMAIN });
}
return res.status(500);
}

View File

@ -6,9 +6,9 @@ import { TracksProvider } from '@dailyjs/shared/contexts/TracksProvider';
import { UIStateProvider } from '@dailyjs/shared/contexts/UIStateProvider';
import { WaitingRoomProvider } from '@dailyjs/shared/contexts/WaitingRoomProvider';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
import PropTypes from 'prop-types';
import App from '../components/App';
import { CreatingRoom } from '../components/CreatingRoom';
import { Intro, NotConfigured } from '../components/Intro';
/**
@ -26,6 +26,7 @@ export default function Index({
forceFetchToken = false,
forceOwner = false,
subscribeToTracksAutomatically = true,
demoMode = false,
asides,
modals,
customTrayComponent,
@ -75,22 +76,24 @@ export default function Index({
if (!isReady) {
return (
<main>
{!isConfigured ? (
<NotConfigured />
) : (
<Intro
forceFetchToken={forceFetchToken}
forceOwner={forceOwner}
title={process.env.PROJECT_TITLE}
room={roomName}
error={tokenError}
fetching={fetchingToken}
domain={domain}
onJoin={(room, isOwner, fetchToken) =>
fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room)
}
/>
)}
{(() => {
if (!isConfigured) return <NotConfigured />;
if (demoMode) return <CreatingRoom onCreated={getMeetingToken} />;
return (
<Intro
forceFetchToken={forceFetchToken}
forceOwner={forceOwner}
title={process.env.PROJECT_TITLE}
room={roomName}
error={tokenError}
fetching={fetchingToken}
domain={domain}
onJoin={(room, isOwner, fetchToken) =>
fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room)
}
/>
);
})()}
<style jsx>{`
height: 100vh;
@ -143,6 +146,7 @@ Index.propTypes = {
forceFetchToken: PropTypes.bool,
forceOwner: PropTypes.bool,
subscribeToTracksAutomatically: PropTypes.bool,
demoMode: PropTypes.bool,
};
export async function getStaticProps() {

View File

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

View File

@ -0,0 +1,31 @@
# Flying Emojis
![Flying Emojis](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-flying-emojis.vercel.app)**
---
## What does this demo do?
- Use [sendAppMessage](https://docs.daily.co/reference#%EF%B8%8F-sendappmessage) to send flying emojis to all clients
- Implements a custom `<App />` that adds `<FlyingEmojisOverlay />` component that listens for incoming emoji events and appends a new node to the DOM
- Todo: pool emoji DOM nodes to optimise on DOM mutations
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/flying-emojis dev
```
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,12 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import FlyingEmojiOverlay from '../FlyingEmojis/FlyingEmojisOverlay';
export const AppWithEmojis = () => (
<>
<FlyingEmojiOverlay />
<App />
</>
);
export default AppWithEmojis;

View File

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

View File

@ -0,0 +1,154 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
const EMOJI_MAP = {
fire: '🔥',
squid: '🦑',
laugh: '🤣',
};
export const FlyingEmojisOverlay = () => {
const { callObject } = useCallState();
const overlayRef = useRef();
// -- Handlers
const handleRemoveFlyingEmoji = useCallback((node) => {
if (!overlayRef.current) return;
overlayRef.current.removeChild(node);
}, []);
const handleNewFlyingEmoji = useCallback(
(emoji) => {
if (!overlayRef.current) {
return;
}
console.log(`⭐ Displaying flying emoji: ${emoji}`);
const node = document.createElement('div');
node.appendChild(document.createTextNode(EMOJI_MAP[emoji]));
node.className =
Math.random() * 1 > 0.5 ? 'emoji wiggle-1' : 'emoji wiggle-2';
node.style.transform = `rotate(${-30 + Math.random() * 60}deg)`;
node.style.left = `${Math.random() * 100}%`;
node.src = '';
overlayRef.current.appendChild(node);
node.addEventListener('animationend', (e) =>
handleRemoveFlyingEmoji(e.target)
);
},
[handleRemoveFlyingEmoji]
);
// -- Effects
// Listen for new app messages and show new flying emojis
useEffect(() => {
if (!callObject) {
return false;
}
console.log(`⭐ Listening for flying emojis...`);
callObject.on('app-message', handleNewFlyingEmoji);
return () => callObject.off('app-message', handleNewFlyingEmoji);
}, [callObject, handleNewFlyingEmoji]);
// Listen to window events to show local user emojis
useEffect(() => {
if (!callObject) {
return false;
}
function handleIncomingEmoji(e) {
const { emoji } = e.detail;
console.log(`⭐ Sending flying emoji: ${emoji}`);
if (emoji) {
callObject.sendAppMessage({ emoji }, '*');
handleNewFlyingEmoji(emoji);
}
}
window.addEventListener('reaction_added', handleIncomingEmoji);
return () =>
window.removeEventListener('reaction_added', handleIncomingEmoji);
}, [callObject, handleNewFlyingEmoji]);
// Remove all event listeners on unmount to prevent console warnings
useEffect(
() => () =>
overlayRef.current.childNodes.forEach((n) =>
n.removeEventListener('animationend', handleRemoveFlyingEmoji)
),
[handleRemoveFlyingEmoji]
);
return (
<div className="flying-emojis" ref={overlayRef}>
<style jsx>{`
.flying-emojis {
position: fixed;
top: 0px;
bottom: 0px;
left: 24px;
right: 24px;
overflow: hidden;
pointer-events: none;
user-select: none;
z-index: 99;
}
.flying-emojis :global(.emoji) {
position: absolute;
bottom: 0px;
left: 50%;
font-size: 48px;
line-height: 1;
width: 48px;
height: 48px;
}
.flying-emojis :global(.emoji.wiggle-1) {
animation: emerge 3s forwards,
wiggle-1 1s ease-in-out infinite alternate;
}
.flying-emojis :global(.emoji.wiggle-2) {
animation: emerge 3s forwards,
wiggle-2 1s ease-in-out infinite alternate;
}
@keyframes emerge {
to {
bottom: 85%;
opacity: 0;
}
}
@keyframes wiggle-1 {
from {
margin-left: -50px;
}
to {
margin-left: 50px;
}
}
@keyframes wiggle-2 {
from {
margin-left: 50px;
}
to {
margin-left: -50px;
}
}
`}</style>
</div>
);
};
export default FlyingEmojisOverlay;

View File

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

View File

@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react';
import Button from '@dailyjs/shared/components/Button';
import { TrayButton } from '@dailyjs/shared/components/Tray';
import { ReactComponent as IconStar } from '@dailyjs/shared/icons/star-md.svg';
const COOLDOWN = 1500;
export const Tray = () => {
const [showEmojis, setShowEmojis] = useState(false);
const [isThrottled, setIsThrottled] = useState(false);
function sendEmoji(emoji) {
// Dispatch custom event here so the local user can see their own emoji
window.dispatchEvent(
new CustomEvent('reaction_added', { detail: { emoji } })
);
setShowEmojis(false);
setIsThrottled(true);
}
// Pseudo-throttling (should ideally be done serverside)
useEffect(() => {
if (!isThrottled) {
return false;
}
const t = setTimeout(() => setIsThrottled(false), COOLDOWN);
return () => clearTimeout(t);
}, [isThrottled]);
return (
<div>
{showEmojis && (
<div className="emojis">
<Button
variant="outline-gray"
size="small-square"
onClick={() => sendEmoji('fire')}
>
🔥
</Button>
<Button
variant="outline-gray"
size="small-square"
onClick={() => sendEmoji('squid')}
>
🦑
</Button>
<Button
variant="outline-gray"
size="small-square"
onClick={() => sendEmoji('laugh')}
>
🤣
</Button>
</div>
)}
<TrayButton
label="Emoji"
onClick={() => setShowEmojis(!showEmojis)}
disabled={isThrottled}
>
<IconStar />
</TrayButton>
<style jsx>{`
position: relative;
.emojis {
position: absolute;
display: flex;
top: calc(-100% + var(--spacing-xs));
left: 0px;
transform: translateX(calc(-50% + 26px));
z-index: 99;
background: white;
padding: var(--spacing-xxxs);
column-gap: 5px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-depth-2);
}
`}</style>
</div>
);
};
export default Tray;

View File

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

View File

@ -0,0 +1,8 @@
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
DAILY_DOMAIN=
# Obtained from https://dashboard.daily.co/developers
DAILY_API_KEY=
# Daily REST API endpoint
DAILY_REST_DOMAIN=https://api.daily.co/v1

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@dailyjs/flying-emojis",
"description": "Basic Call + Flying Emojis",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/basic-call": "*",
"@dailyjs/shared": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithEmojis from '../components/App';
import Tray from '../components/Tray';
App.customAppComponent = <AppWithEmojis />;
App.customTrayComponent = <Tray />;
export default App;

View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,13 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
// Pass through domain as prop
return {
props: defaultProps,
};
}
export default Index;

View File

@ -0,0 +1 @@
../basic-call/public

View File

@ -0,0 +1,8 @@
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
DAILY_DOMAIN=
# Obtained from https://dashboard.daily.co/developers
DAILY_API_KEY=
# Daily REST API endpoint
DAILY_REST_DOMAIN=https://api.daily.co/v1

View File

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

View File

@ -0,0 +1,40 @@
# Recording
![Recording](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-recording.vercel.app)**
---
## What does this demo do?
- Use [startRecording](https://docs.daily.co/reference#%EF%B8%8F-startrecording) to create a video and audio recording of your call. You can read more about Daily call recording (and the different modes and types) [here](https://docs.daily.co/reference#recordings)
- Supports both `cloud` and `local` recording modes (specified when creating the room or managed using the Daily dashboard)
- Coming soon: support different recording layouts / composites
- Coming soon: use the Daily REST API to retrieve a list of cloud recordings for the currently active room
**To turn on recording, you need to be on the Scale plan. There is also a per minute recording fee for cloud recording.**
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/recording dev
```
### How does this demo work?
This example introduces a new [RecordingProvider](./contexts/RecordingProvider.js) context that listens for the various [recording events](https://docs.daily.co/reference#recording-started), counts down to begin a recording and stops a currently active recording. We also introduce a new recording modal and tray button.
Remember to follow the best practises detailed in [the documentation](https://docs.daily.co/reference#recordings) to avoid lengthy or stuck recordings.
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,13 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import { RecordingProvider } from '../../contexts/RecordingProvider';
// Extend our basic call app component with the recording context
export const AppWithRecording = () => (
<RecordingProvider>
<App />
</RecordingProvider>
);
export default AppWithRecording;

View File

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

View File

@ -0,0 +1,124 @@
import React, { useEffect } from 'react';
import { Button } from '@dailyjs/shared/components/Button';
import { CardBody } from '@dailyjs/shared/components/Card';
import Modal from '@dailyjs/shared/components/Modal';
import Well from '@dailyjs/shared/components/Well';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { enable } from 'debug';
import {
RECORDING_COUNTDOWN_1,
RECORDING_COUNTDOWN_2,
RECORDING_COUNTDOWN_3,
RECORDING_ERROR,
RECORDING_IDLE,
RECORDING_RECORDING,
RECORDING_SAVED,
RECORDING_TYPE_CLOUD,
RECORDING_UPLOADING,
useRecording,
} from '../../contexts/RecordingProvider';
export const RECORDING_MODAL = 'recording';
export const RecordingModal = () => {
const { currentModals, closeModal } = useUIState();
const { enableRecording } = useCallState();
const {
recordingStartedDate,
recordingState,
startRecordingWithCountdown,
stopRecording,
} = useRecording();
useEffect(() => {
if (recordingState === RECORDING_RECORDING) {
closeModal(RECORDING_MODAL);
}
}, [recordingState, closeModal]);
const disabled =
enableRecording &&
[RECORDING_IDLE, RECORDING_RECORDING].includes(recordingState);
function renderButtonLabel() {
if (!enableRecording) {
return 'Recording disabled';
}
switch (recordingState) {
case RECORDING_COUNTDOWN_3:
return '3...';
case RECORDING_COUNTDOWN_2:
return '2...';
case RECORDING_COUNTDOWN_1:
return '1...';
case RECORDING_RECORDING:
return 'Stop recording';
case RECORDING_UPLOADING:
case RECORDING_SAVED:
return 'Stopping recording...';
default:
return 'Start recording';
}
}
function handleRecordingClick() {
if (recordingState === RECORDING_IDLE) {
startRecordingWithCountdown();
} else {
stopRecording();
}
}
return (
<Modal
title="Recording"
isOpen={currentModals[RECORDING_MODAL]}
onClose={() => closeModal(RECORDING_MODAL)}
actions={[
<Button fullWidth variant="outline">
Close
</Button>,
<Button
fullWidth
disabled={!disabled}
onClick={() => handleRecordingClick()}
>
{renderButtonLabel()}
</Button>,
]}
>
<CardBody>
{!enableRecording ? (
<Well variant="error">
Recording is not enabled for this room (or your browser does not
support it.) Please enabled recording when creating the room or via
the Daily dashboard.
</Well>
) : (
<p>
Recording type enabled: <strong>{enableRecording}</strong>
</p>
)}
{recordingStartedDate && (
<p>Recording started: {recordingStartedDate.toString()}</p>
)}
{enableRecording === RECORDING_TYPE_CLOUD && (
<>
<hr />
<p>
Cloud recordings can be accessed via the Daily dashboard under the
&quot;Recordings&quot; section.
</p>
</>
)}
</CardBody>
</Modal>
);
};
export default RecordingModal;

View File

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

View File

@ -0,0 +1,47 @@
import React, { useEffect } from 'react';
import { TrayButton } from '@dailyjs/shared/components/Tray';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { ReactComponent as IconRecord } from '@dailyjs/shared/icons/record-md.svg';
import {
RECORDING_ERROR,
RECORDING_RECORDING,
RECORDING_SAVED,
RECORDING_UPLOADING,
useRecording,
} from '../../contexts/RecordingProvider';
import { RECORDING_MODAL } from '../RecordingModal';
export const Tray = () => {
const { openModal } = useUIState();
const { recordingState } = useRecording();
useEffect(() => {
console.log(`⏺️ Recording state: ${recordingState}`);
if (recordingState === RECORDING_ERROR) {
// show error modal here
}
}, [recordingState]);
const isRecording = [
RECORDING_RECORDING,
RECORDING_UPLOADING,
RECORDING_SAVED,
].includes(recordingState);
return (
<>
<TrayButton
label={isRecording ? 'Recording' : 'Record'}
orange={isRecording}
onClick={() => openModal(RECORDING_MODAL)}
>
<IconRecord />
</TrayButton>
</>
);
};
export default Tray;

View File

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

View File

@ -0,0 +1,304 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import {
CALL_STATE_REDIRECTING,
CALL_STATE_JOINED,
} from '@dailyjs/shared/contexts/useCallMachine';
import PropTypes from 'prop-types';
import { useDeepCompareEffect } from 'use-deep-compare';
export const RECORDING_ERROR = 'error';
export const RECORDING_SAVED = 'saved';
export const RECORDING_RECORDING = 'recording';
export const RECORDING_UPLOADING = 'uploading';
export const RECORDING_COUNTDOWN_1 = 'starting1';
export const RECORDING_COUNTDOWN_2 = 'starting2';
export const RECORDING_COUNTDOWN_3 = 'starting3';
export const RECORDING_IDLE = 'idle';
export const RECORDING_TYPE_CLOUD = 'cloud';
export const RECORDING_TYPE_LOCAL = 'local';
const RecordingContext = createContext({
isRecordingLocally: false,
recordingStartedDate: null,
recordingState: RECORDING_IDLE,
startRecording: null,
stopRecording: null,
});
export const RecordingProvider = ({ children }) => {
const { callObject, enableRecording, startCloudRecording, state } =
useCallState();
const { participants } = useParticipants();
const [recordingStartedDate, setRecordingStartedDate] = useState(null);
const [recordingState, setRecordingState] = useState(RECORDING_IDLE);
const [isRecordingLocally, setIsRecordingLocally] = useState(false);
const [hasRecordingStarted, setHasRecordingStarted] = useState(false);
const { setCustomCapsule } = useUIState();
const handleOnUnload = useCallback(
() => 'Unsaved recording in progress. Do you really want to leave?',
[]
);
useEffect(() => {
if (
!enableRecording ||
!isRecordingLocally ||
recordingState !== RECORDING_RECORDING ||
state === CALL_STATE_REDIRECTING
)
return false;
const prev = window.onbeforeunload;
window.onbeforeunload = handleOnUnload;
return () => {
window.onbeforeunload = prev;
};
}, [
enableRecording,
handleOnUnload,
recordingState,
isRecordingLocally,
state,
]);
useEffect(() => {
if (!callObject || !enableRecording) return false;
const handleAppMessage = (ev) => {
switch (ev?.data?.event) {
case 'recording-starting':
setRecordingState(RECORDING_COUNTDOWN_3);
break;
default:
break;
}
};
const handleRecordingUploadCompleted = () => {
setRecordingState(RECORDING_SAVED);
};
callObject.on('app-message', handleAppMessage);
callObject.on('recording-upload-completed', handleRecordingUploadCompleted);
return () => {
callObject.off('app-message', handleAppMessage);
callObject.off(
'recording-upload-completed',
handleRecordingUploadCompleted
);
};
}, [callObject, enableRecording]);
/**
* Automatically start cloud recording, if startCloudRecording is set.
*/
useEffect(() => {
if (
hasRecordingStarted ||
!callObject ||
!startCloudRecording ||
enableRecording !== 'cloud' ||
state !== CALL_STATE_JOINED
)
return false;
// Small timeout, in case other participants are already in-call.
const timeout = setTimeout(() => {
const isSomebodyRecording = participants.some((p) => p.isRecording);
if (!isSomebodyRecording) {
callObject.startRecording();
setIsRecordingLocally(true);
setHasRecordingStarted(true);
} else {
setHasRecordingStarted(true);
}
}, 500);
return () => {
clearTimeout(timeout);
};
}, [
callObject,
enableRecording,
hasRecordingStarted,
participants,
startCloudRecording,
state,
]);
/**
* Handle participant updates to sync recording state.
*/
useDeepCompareEffect(() => {
if (isRecordingLocally || recordingState === RECORDING_SAVED) return;
if (participants.some(({ isRecording }) => isRecording)) {
setRecordingState(RECORDING_RECORDING);
} else {
setRecordingState(RECORDING_IDLE);
}
}, [isRecordingLocally, participants, recordingState]);
/**
* Handle recording started.
*/
const handleRecordingStarted = useCallback(
(event) => {
if (recordingState === RECORDING_RECORDING) return;
if (event.local) {
// Recording started locally, either through UI or programmatically
setIsRecordingLocally(true);
if (!recordingStartedDate) setRecordingStartedDate(new Date());
}
setRecordingState(RECORDING_RECORDING);
},
[recordingState, recordingStartedDate]
);
useEffect(() => {
if (!callObject || !enableRecording) return false;
callObject.on('recording-started', handleRecordingStarted);
return () => callObject.off('recording-started', handleRecordingStarted);
}, [callObject, enableRecording, handleRecordingStarted]);
/**
* Handle recording stopped.
*/
useEffect(() => {
if (!callObject || !enableRecording) return false;
const handleRecordingStopped = () => {
if (isRecordingLocally) return;
setRecordingState(RECORDING_IDLE);
setRecordingStartedDate(null);
};
callObject.on('recording-stopped', handleRecordingStopped);
return () => callObject.off('recording-stopped', handleRecordingStopped);
}, [callObject, enableRecording, isRecordingLocally]);
/**
* Handle recording error.
*/
const handleRecordingError = useCallback(() => {
if (isRecordingLocally) setRecordingState(RECORDING_ERROR);
setIsRecordingLocally(false);
}, [isRecordingLocally]);
useEffect(() => {
if (!callObject || !enableRecording) return false;
callObject.on('recording-error', handleRecordingError);
return () => callObject.off('recording-error', handleRecordingError);
}, [callObject, enableRecording, handleRecordingError]);
const startRecording = useCallback(() => {
if (!callObject || !isRecordingLocally) return;
callObject.startRecording();
}, [callObject, isRecordingLocally]);
useEffect(() => {
let timeout;
switch (recordingState) {
case RECORDING_COUNTDOWN_3:
timeout = setTimeout(() => {
setRecordingState(RECORDING_COUNTDOWN_2);
}, 1000);
break;
case RECORDING_COUNTDOWN_2:
timeout = setTimeout(() => {
setRecordingState(RECORDING_COUNTDOWN_1);
}, 1000);
break;
case RECORDING_COUNTDOWN_1:
startRecording();
break;
case RECORDING_ERROR:
case RECORDING_SAVED:
timeout = setTimeout(() => {
setRecordingState(RECORDING_IDLE);
setIsRecordingLocally(false);
}, 5000);
break;
default:
break;
}
return () => {
clearTimeout(timeout);
};
}, [recordingState, startRecording]);
// Show a custom capsule when recording in progress
useEffect(() => {
if (recordingState !== RECORDING_RECORDING) {
setCustomCapsule(null);
} else {
setCustomCapsule({ variant: 'recording', label: 'Recording' });
}
}, [recordingState, setCustomCapsule]);
const startRecordingWithCountdown = useCallback(() => {
if (!callObject || !enableRecording) return;
setIsRecordingLocally(true);
setRecordingState(RECORDING_COUNTDOWN_3);
callObject?.sendAppMessage({
event: 'recording-starting',
});
}, [callObject, enableRecording]);
const stopRecording = useCallback(() => {
if (!callObject || !enableRecording || !isRecordingLocally) return;
if (recordingState === RECORDING_RECORDING) {
switch (enableRecording) {
case RECORDING_TYPE_LOCAL:
setRecordingState(RECORDING_SAVED);
setIsRecordingLocally(false);
break;
case RECORDING_TYPE_CLOUD:
setRecordingState(RECORDING_UPLOADING);
break;
default:
break;
}
} else if (recordingState === RECORDING_IDLE) {
return;
} else {
setIsRecordingLocally(false);
setRecordingState(RECORDING_IDLE);
}
setRecordingStartedDate(null);
callObject.stopRecording();
}, [callObject, enableRecording, isRecordingLocally, recordingState]);
return (
<RecordingContext.Provider
value={{
isRecordingLocally,
recordingStartedDate,
recordingState,
startRecordingWithCountdown,
stopRecording,
}}
>
{children}
</RecordingContext.Provider>
);
};
RecordingProvider.propTypes = {
children: PropTypes.node,
};
export const useRecording = () => useContext(RecordingContext);

View File

@ -0,0 +1,8 @@
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
DAILY_DOMAIN=
# Obtained from https://dashboard.daily.co/developers
DAILY_API_KEY=
# Daily REST API endpoint
DAILY_REST_DOMAIN=https://api.daily.co/v1

BIN
dailyjs/recording/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@dailyjs/recording",
"description": "Basic Call + Recording",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",
"@dailyjs/basic-call": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithRecording from '../components/App';
import { RecordingModal } from '../components/RecordingModal';
import Tray from '../components/Tray';
App.modals = [RecordingModal];
App.customAppComponent = <AppWithRecording />;
App.customTrayComponent = <Tray />;
export default App;

1
dailyjs/recording/pages/api Symbolic link
View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,16 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: {
...defaultProps,
forceFetchToken: true,
forceOwner: true,
},
};
}
export default Index;

1
dailyjs/recording/public Symbolic link
View File

@ -0,0 +1 @@
../basic-call/public

View File

@ -286,6 +286,9 @@ export const Button = forwardRef(
.button.dark:focus {
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
}
.button.dark:disabled {
opacity: 0.35;
}
.button.outline {
background: transparent;

View File

@ -9,11 +9,17 @@ export const TrayButton = ({
onClick,
bubble = false,
orange = false,
disabled = false,
}) => {
const cx = classNames('tray-button', { orange, bubble });
return (
<div className={cx}>
<Button onClick={() => onClick()} variant="dark" size="large-square">
<Button
onClick={() => onClick()}
variant="dark"
size="large-square"
disabled={disabled}
>
{children}
</Button>
<span>{label}</span>
@ -57,6 +63,7 @@ TrayButton.propTypes = {
orange: PropTypes.bool,
bubble: PropTypes.bool,
label: PropTypes.string.isRequired,
disabled: PropTypes.bool,
};
export const Tray = ({ children }) => (

View File

@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
} from 'react';
import Bowser from 'bowser';
import PropTypes from 'prop-types';
import {
ACCESS_STATE_LOBBY,
@ -31,6 +32,9 @@ export const CallProvider = ({
}) => {
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
const [enableRecording, setEnableRecording] = useState(null);
const [startCloudRecording, setStartCloudRecording] = useState(false);
const [roomExp, setRoomExp] = useState(null);
// Daily CallMachine hook (primarily handles status of the call)
const { daily, leave, state, setRedirectOnLeave } = useCallMachine({
@ -40,6 +44,37 @@ export const CallProvider = ({
subscribeToTracksAutomatically,
});
// Feature detection taken from daily room object and client browser support
useEffect(() => {
if (!daily) return;
const updateRoomConfigState = async () => {
const roomConfig = await daily.room();
if (!('config' in roomConfig)) return;
if (roomConfig?.config?.exp) {
setRoomExp(
roomConfig?.config?.exp * 1000 || Date.now() + 1 * 60 * 1000
);
}
const browser = Bowser.parse(window.navigator.userAgent);
const supportsRecording =
browser.platform.type === 'desktop' && browser.engine.name === 'Blink';
// recording and screen sharing is hidden in owner_only_broadcast for non-owners
if (supportsRecording) {
const recordingType =
roomConfig?.tokenConfig?.enable_recording ??
roomConfig?.config?.enable_recording;
if (['local', 'cloud'].includes(recordingType)) {
setEnableRecording(recordingType);
setStartCloudRecording(
roomConfig?.tokenConfig?.start_cloud_recording ?? false
);
}
}
};
updateRoomConfigState();
}, [state, daily]);
// Convience wrapper for adding a fake participant to the call
const addFakeParticipant = useCallback(() => {
daily.addFakeParticipant();
@ -71,10 +106,13 @@ export const CallProvider = ({
addFakeParticipant,
preJoinNonAuthorized,
leave,
roomExp,
videoQuality,
enableRecording,
setVideoQuality,
setBandwidth,
setRedirectOnLeave,
startCloudRecording,
subscribeToTracksAutomatically,
}}
>

View File

@ -57,6 +57,7 @@ export const UIStateProvider = ({
UIStateProvider.propTypes = {
children: PropTypes.node,
demoMode: PropTypes.bool,
asides: PropTypes.arrayOf(PropTypes.func),
modals: PropTypes.arrayOf(PropTypes.func),
customTrayComponent: PropTypes.node,

View File

@ -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="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="4" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="none" stroke="currentColor"><polygon points="12,2.49 15.09,8.75 22,9.754 17,14.628 18.18,21.51 12,18.262 5.82,21.51 7,14.628 2,9.754 8.91,8.75 "></polygon></g></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -7,5 +7,7 @@ export default function getDemoProps() {
predefinedRoom: process.env.DAILY_ROOM || '',
// Manual or automatic track subscriptions
subscribeToTracksAutomatically: !process.env.MANUAL_TRACK_SUBS,
// Are we running in demo mode? (automatically creates a short-expiry room)
demoMode: !!process.env.DAILY_DEMO_MODE,
};
}

View File

@ -4,7 +4,8 @@
"private": true,
"main": "index.js",
"dependencies": {
"@daily-co/daily-js": "^0.14.0",
"@daily-co/daily-js": "^0.15.0",
"bowser": "^2.11.0",
"classnames": "^2.3.1",
"debounce": "^1.2.1",
"fast-deep-equal": "^3.1.3",

View File

@ -0,0 +1,8 @@
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
DAILY_DOMAIN=
# Obtained from https://dashboard.daily.co/developers
DAILY_API_KEY=
# Daily REST API endpoint
DAILY_REST_DOMAIN=https://api.daily.co/v1

View File

@ -160,10 +160,17 @@
"@babel/helper-validator-identifier" "^7.12.11"
to-fast-properties "^2.0.0"
<<<<<<< HEAD
"@daily-co/daily-js@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.14.0.tgz#29308c77e00886514df7d932d771980d5cdb7618"
integrity sha512-OD2epVohYraTfOH/ZuO5rP9Ej4Rfu/ufGXX0XJQG+mAu1hJ1610JWunnszTmfhk+uUH4aA9i7+5/PQ2meOXUtQ==
=======
"@daily-co/daily-js@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.15.0.tgz#9dfd5c3ed8855df31c370d5b21a3b5098cce3c4f"
integrity sha512-rnivho7yx/yEOtqL81L4daPy9C/FDXf06k06df8vmyUXsE8y+cxSTD7ZvYIJDGJHN6IZRhVxxfbCyPI8CHfwCg==
>>>>>>> 1068a9f75322d380546319034f8c236029567085
dependencies:
"@babel/runtime" "^7.12.5"
bowser "^2.8.1"
@ -584,7 +591,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
bowser@^2.8.1:
bowser@^2.11.0, bowser@^2.8.1:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==