resolve conflicts
This commit is contained in:
commit
137afb1831
|
|
@ -10,7 +10,15 @@ Send messages to other participants using sendAppMessage
|
||||||
|
|
||||||
### [📺 Live streaming](./live-streaming)
|
### [📺 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)
|
### [📃 Pagination](./pagination)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||||
import { useCallUI } from '@dailyjs/shared/hooks/useCallUI';
|
import { useCallUI } from '@dailyjs/shared/hooks/useCallUI';
|
||||||
|
|
||||||
|
|
@ -8,7 +8,20 @@ import { Asides } from './Asides';
|
||||||
import { Modals } from './Modals';
|
import { Modals } from './Modals';
|
||||||
|
|
||||||
export const App = ({ customComponentForState }) => {
|
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({
|
const componentForState = useCallUI({
|
||||||
state,
|
state,
|
||||||
|
|
@ -17,7 +30,7 @@ export const App = ({ customComponentForState }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize children to avoid unnecassary renders from HOC
|
// Memoize children to avoid unnecassary renders from HOC
|
||||||
return useMemo(
|
const memoizedApp = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{componentForState()}
|
{componentForState()}
|
||||||
|
|
@ -38,11 +51,32 @@ export const App = ({ customComponentForState }) => {
|
||||||
),
|
),
|
||||||
[componentForState]
|
[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 = {
|
App.propTypes = {
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
customComponentForState: PropTypes.any,
|
||||||
customComponentsForState: PropTypes.any,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { CreatingRoom as default } from './CreatingRoom';
|
||||||
|
export { CreatingRoom } from './CreatingRoom';
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,9 @@ import { TracksProvider } from '@dailyjs/shared/contexts/TracksProvider';
|
||||||
import { UIStateProvider } from '@dailyjs/shared/contexts/UIStateProvider';
|
import { UIStateProvider } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
import { WaitingRoomProvider } from '@dailyjs/shared/contexts/WaitingRoomProvider';
|
import { WaitingRoomProvider } from '@dailyjs/shared/contexts/WaitingRoomProvider';
|
||||||
import getDemoProps from '@dailyjs/shared/lib/demoProps';
|
import getDemoProps from '@dailyjs/shared/lib/demoProps';
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import App from '../components/App';
|
import App from '../components/App';
|
||||||
|
import { CreatingRoom } from '../components/CreatingRoom';
|
||||||
import { Intro, NotConfigured } from '../components/Intro';
|
import { Intro, NotConfigured } from '../components/Intro';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,6 +26,7 @@ export default function Index({
|
||||||
forceFetchToken = false,
|
forceFetchToken = false,
|
||||||
forceOwner = false,
|
forceOwner = false,
|
||||||
subscribeToTracksAutomatically = true,
|
subscribeToTracksAutomatically = true,
|
||||||
|
demoMode = false,
|
||||||
asides,
|
asides,
|
||||||
modals,
|
modals,
|
||||||
customTrayComponent,
|
customTrayComponent,
|
||||||
|
|
@ -75,9 +76,10 @@ export default function Index({
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
{!isConfigured ? (
|
{(() => {
|
||||||
<NotConfigured />
|
if (!isConfigured) return <NotConfigured />;
|
||||||
) : (
|
if (demoMode) return <CreatingRoom onCreated={getMeetingToken} />;
|
||||||
|
return (
|
||||||
<Intro
|
<Intro
|
||||||
forceFetchToken={forceFetchToken}
|
forceFetchToken={forceFetchToken}
|
||||||
forceOwner={forceOwner}
|
forceOwner={forceOwner}
|
||||||
|
|
@ -90,7 +92,8 @@ export default function Index({
|
||||||
fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room)
|
fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -143,6 +146,7 @@ Index.propTypes = {
|
||||||
forceFetchToken: PropTypes.bool,
|
forceFetchToken: PropTypes.bool,
|
||||||
forceOwner: PropTypes.bool,
|
forceOwner: PropTypes.bool,
|
||||||
subscribeToTracksAutomatically: PropTypes.bool,
|
subscribeToTracksAutomatically: PropTypes.bool,
|
||||||
|
demoMode: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": ["inline-react-svg"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Flying Emojis
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
[](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)
|
||||||
|
|
@ -0,0 +1,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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { AppWithEmojis as default } from './App';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { FlyingEmojisOverlay } from './FlyingEmojisOverlay';
|
||||||
|
export { FlyingEmojisOverlay as default } from './FlyingEmojisOverlay';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { Tray as default } from './Tray';
|
||||||
|
|
@ -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 |
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../../basic-call/pages/api
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../basic-call/public
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": ["inline-react-svg"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Recording
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
[](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)
|
||||||
|
|
@ -0,0 +1,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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { AppWithRecording as default } from './App';
|
||||||
|
|
@ -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
|
||||||
|
"Recordings" section.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecordingModal;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { RecordingModal as default } from './RecordingModal';
|
||||||
|
export { RecordingModal } from './RecordingModal';
|
||||||
|
export { RECORDING_MODAL } from './RecordingModal';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { Tray as default } from './Tray';
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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: 281 KiB |
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../../basic-call/pages/api
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../basic-call/public
|
||||||
|
|
@ -286,6 +286,9 @@ export const Button = forwardRef(
|
||||||
.button.dark:focus {
|
.button.dark:focus {
|
||||||
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
|
box-shadow: 0 0 0px 3px rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
.button.dark:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
.button.outline {
|
.button.outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,17 @@ export const TrayButton = ({
|
||||||
onClick,
|
onClick,
|
||||||
bubble = false,
|
bubble = false,
|
||||||
orange = false,
|
orange = false,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const cx = classNames('tray-button', { orange, bubble });
|
const cx = classNames('tray-button', { orange, bubble });
|
||||||
return (
|
return (
|
||||||
<div className={cx}>
|
<div className={cx}>
|
||||||
<Button onClick={() => onClick()} variant="dark" size="large-square">
|
<Button
|
||||||
|
onClick={() => onClick()}
|
||||||
|
variant="dark"
|
||||||
|
size="large-square"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|
@ -57,6 +63,7 @@ TrayButton.propTypes = {
|
||||||
orange: PropTypes.bool,
|
orange: PropTypes.bool,
|
||||||
bubble: PropTypes.bool,
|
bubble: PropTypes.bool,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tray = ({ children }) => (
|
export const Tray = ({ children }) => (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import Bowser from 'bowser';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
ACCESS_STATE_LOBBY,
|
ACCESS_STATE_LOBBY,
|
||||||
|
|
@ -31,6 +32,9 @@ export const CallProvider = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
|
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
|
||||||
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
|
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)
|
// Daily CallMachine hook (primarily handles status of the call)
|
||||||
const { daily, leave, state, setRedirectOnLeave } = useCallMachine({
|
const { daily, leave, state, setRedirectOnLeave } = useCallMachine({
|
||||||
|
|
@ -40,6 +44,37 @@ export const CallProvider = ({
|
||||||
subscribeToTracksAutomatically,
|
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
|
// Convience wrapper for adding a fake participant to the call
|
||||||
const addFakeParticipant = useCallback(() => {
|
const addFakeParticipant = useCallback(() => {
|
||||||
daily.addFakeParticipant();
|
daily.addFakeParticipant();
|
||||||
|
|
@ -71,10 +106,13 @@ export const CallProvider = ({
|
||||||
addFakeParticipant,
|
addFakeParticipant,
|
||||||
preJoinNonAuthorized,
|
preJoinNonAuthorized,
|
||||||
leave,
|
leave,
|
||||||
|
roomExp,
|
||||||
videoQuality,
|
videoQuality,
|
||||||
|
enableRecording,
|
||||||
setVideoQuality,
|
setVideoQuality,
|
||||||
setBandwidth,
|
setBandwidth,
|
||||||
setRedirectOnLeave,
|
setRedirectOnLeave,
|
||||||
|
startCloudRecording,
|
||||||
subscribeToTracksAutomatically,
|
subscribeToTracksAutomatically,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export const UIStateProvider = ({
|
||||||
|
|
||||||
UIStateProvider.propTypes = {
|
UIStateProvider.propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
demoMode: PropTypes.bool,
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
asides: PropTypes.arrayOf(PropTypes.func),
|
||||||
modals: PropTypes.arrayOf(PropTypes.func),
|
modals: PropTypes.arrayOf(PropTypes.func),
|
||||||
customTrayComponent: PropTypes.node,
|
customTrayComponent: PropTypes.node,
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -7,5 +7,7 @@ export default function getDemoProps() {
|
||||||
predefinedRoom: process.env.DAILY_ROOM || '',
|
predefinedRoom: process.env.DAILY_ROOM || '',
|
||||||
// Manual or automatic track subscriptions
|
// Manual or automatic track subscriptions
|
||||||
subscribeToTracksAutomatically: !process.env.MANUAL_TRACK_SUBS,
|
subscribeToTracksAutomatically: !process.env.MANUAL_TRACK_SUBS,
|
||||||
|
// Are we running in demo mode? (automatically creates a short-expiry room)
|
||||||
|
demoMode: !!process.env.DAILY_DEMO_MODE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@daily-co/daily-js": "^0.14.0",
|
"@daily-co/daily-js": "^0.15.0",
|
||||||
|
"bowser": "^2.11.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"debounce": "^1.2.1",
|
"debounce": "^1.2.1",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -160,10 +160,17 @@
|
||||||
"@babel/helper-validator-identifier" "^7.12.11"
|
"@babel/helper-validator-identifier" "^7.12.11"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
"@daily-co/daily-js@^0.14.0":
|
"@daily-co/daily-js@^0.14.0":
|
||||||
version "0.14.0"
|
version "0.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.14.0.tgz#29308c77e00886514df7d932d771980d5cdb7618"
|
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==
|
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:
|
dependencies:
|
||||||
"@babel/runtime" "^7.12.5"
|
"@babel/runtime" "^7.12.5"
|
||||||
bowser "^2.8.1"
|
bowser "^2.8.1"
|
||||||
|
|
@ -584,7 +591,7 @@ boolbase@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||||
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
|
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
|
||||||
|
|
||||||
bowser@^2.8.1:
|
bowser@^2.11.0, bowser@^2.8.1:
|
||||||
version "2.11.0"
|
version "2.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
|
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
|
||||||
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
|
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue