diff --git a/dailyjs/README.md b/dailyjs/README.md index a927fec..81f366e 100644 --- a/dailyjs/README.md +++ b/dailyjs/README.md @@ -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) diff --git a/dailyjs/basic-call/components/App/App.js b/dailyjs/basic-call/components/App/App.js index 45b4c07..425457a 100644 --- a/dailyjs/basic-call/components/App/App.js +++ b/dailyjs/basic-call/components/App/App.js @@ -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( () => (
{componentForState()} @@ -38,11 +51,32 @@ export const App = ({ customComponentForState }) => { ), [componentForState] ); + + return ( + <> + {roomExp &&
{secs}
} {memoizedApp} + + + ); }; App.propTypes = { - asides: PropTypes.arrayOf(PropTypes.func), - customComponentsForState: PropTypes.any, + customComponentForState: PropTypes.any, }; export default App; diff --git a/dailyjs/basic-call/components/CreatingRoom/CreatingRoom.js b/dailyjs/basic-call/components/CreatingRoom/CreatingRoom.js new file mode 100644 index 0000000..4973120 --- /dev/null +++ b/dailyjs/basic-call/components/CreatingRoom/CreatingRoom.js @@ -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 ( +
+ {fetching && ( +
+ Creating new demo room... +
+ )} + {error && ( + + An error occured + + {error} + An error occured when trying to create a demo room. Please check + that your environmental variables are correct and try again. + + + )} + + +
+ ); +}; + +CreatingRoom.propTypes = { + onCreated: PropTypes.func.isRequired, +}; + +export default CreatingRoom; diff --git a/dailyjs/basic-call/components/CreatingRoom/index.js b/dailyjs/basic-call/components/CreatingRoom/index.js new file mode 100644 index 0000000..c92b6f2 --- /dev/null +++ b/dailyjs/basic-call/components/CreatingRoom/index.js @@ -0,0 +1,2 @@ +export { CreatingRoom as default } from './CreatingRoom'; +export { CreatingRoom } from './CreatingRoom'; diff --git a/dailyjs/basic-call/pages/api/createRoom.js b/dailyjs/basic-call/pages/api/createRoom.js new file mode 100644 index 0000000..1c8ff2b --- /dev/null +++ b/dailyjs/basic-call/pages/api/createRoom.js @@ -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); +} diff --git a/dailyjs/basic-call/pages/index.js b/dailyjs/basic-call/pages/index.js index ea8a8da..d2c213b 100644 --- a/dailyjs/basic-call/pages/index.js +++ b/dailyjs/basic-call/pages/index.js @@ -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 (
- {!isConfigured ? ( - - ) : ( - - fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room) - } - /> - )} + {(() => { + if (!isConfigured) return ; + if (demoMode) return ; + return ( + + fetchToken ? getMeetingToken(room, isOwner) : setRoomName(room) + } + /> + ); + })()} +
+ ); +}; + +export default FlyingEmojisOverlay; diff --git a/dailyjs/flying-emojis/components/FlyingEmojis/index.js b/dailyjs/flying-emojis/components/FlyingEmojis/index.js new file mode 100644 index 0000000..6e98cc3 --- /dev/null +++ b/dailyjs/flying-emojis/components/FlyingEmojis/index.js @@ -0,0 +1,2 @@ +export { FlyingEmojisOverlay } from './FlyingEmojisOverlay'; +export { FlyingEmojisOverlay as default } from './FlyingEmojisOverlay'; diff --git a/dailyjs/flying-emojis/components/Tray/Tray.js b/dailyjs/flying-emojis/components/Tray/Tray.js new file mode 100644 index 0000000..113de3d --- /dev/null +++ b/dailyjs/flying-emojis/components/Tray/Tray.js @@ -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 ( +
+ {showEmojis && ( +
+ + + +
+ )} + setShowEmojis(!showEmojis)} + disabled={isThrottled} + > + + + +
+ ); +}; + +export default Tray; diff --git a/dailyjs/flying-emojis/components/Tray/index.js b/dailyjs/flying-emojis/components/Tray/index.js new file mode 100644 index 0000000..100bcc8 --- /dev/null +++ b/dailyjs/flying-emojis/components/Tray/index.js @@ -0,0 +1 @@ +export { Tray as default } from './Tray'; diff --git a/dailyjs/flying-emojis/env.example b/dailyjs/flying-emojis/env.example new file mode 100644 index 0000000..5ab7e03 --- /dev/null +++ b/dailyjs/flying-emojis/env.example @@ -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 diff --git a/dailyjs/flying-emojis/image.png b/dailyjs/flying-emojis/image.png new file mode 100644 index 0000000..592ea98 Binary files /dev/null and b/dailyjs/flying-emojis/image.png differ diff --git a/dailyjs/flying-emojis/next.config.js b/dailyjs/flying-emojis/next.config.js new file mode 100644 index 0000000..9a0a6ee --- /dev/null +++ b/dailyjs/flying-emojis/next.config.js @@ -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, + }, +}); diff --git a/dailyjs/flying-emojis/package.json b/dailyjs/flying-emojis/package.json new file mode 100644 index 0000000..a084b01 --- /dev/null +++ b/dailyjs/flying-emojis/package.json @@ -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" + } +} diff --git a/dailyjs/flying-emojis/pages/_app.js b/dailyjs/flying-emojis/pages/_app.js new file mode 100644 index 0000000..16a9742 --- /dev/null +++ b/dailyjs/flying-emojis/pages/_app.js @@ -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 = ; +App.customTrayComponent = ; + +export default App; diff --git a/dailyjs/flying-emojis/pages/api b/dailyjs/flying-emojis/pages/api new file mode 120000 index 0000000..999f604 --- /dev/null +++ b/dailyjs/flying-emojis/pages/api @@ -0,0 +1 @@ +../../basic-call/pages/api \ No newline at end of file diff --git a/dailyjs/flying-emojis/pages/index.js b/dailyjs/flying-emojis/pages/index.js new file mode 100644 index 0000000..d25e77e --- /dev/null +++ b/dailyjs/flying-emojis/pages/index.js @@ -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; diff --git a/dailyjs/flying-emojis/public b/dailyjs/flying-emojis/public new file mode 120000 index 0000000..33a6e67 --- /dev/null +++ b/dailyjs/flying-emojis/public @@ -0,0 +1 @@ +../basic-call/public \ No newline at end of file diff --git a/dailyjs/live-streaming/env.example b/dailyjs/live-streaming/env.example new file mode 100644 index 0000000..5ab7e03 --- /dev/null +++ b/dailyjs/live-streaming/env.example @@ -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 diff --git a/dailyjs/recording/.babelrc b/dailyjs/recording/.babelrc new file mode 100644 index 0000000..a6f4434 --- /dev/null +++ b/dailyjs/recording/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["inline-react-svg"] +} diff --git a/dailyjs/recording/README.md b/dailyjs/recording/README.md new file mode 100644 index 0000000..174013f --- /dev/null +++ b/dailyjs/recording/README.md @@ -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) diff --git a/dailyjs/recording/components/App/App.js b/dailyjs/recording/components/App/App.js new file mode 100644 index 0000000..393d2ad --- /dev/null +++ b/dailyjs/recording/components/App/App.js @@ -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 = () => ( + + + +); + +export default AppWithRecording; diff --git a/dailyjs/recording/components/App/index.js b/dailyjs/recording/components/App/index.js new file mode 100644 index 0000000..82a89dc --- /dev/null +++ b/dailyjs/recording/components/App/index.js @@ -0,0 +1 @@ +export { AppWithRecording as default } from './App'; diff --git a/dailyjs/recording/components/RecordingModal/RecordingModal.js b/dailyjs/recording/components/RecordingModal/RecordingModal.js new file mode 100644 index 0000000..27d96e1 --- /dev/null +++ b/dailyjs/recording/components/RecordingModal/RecordingModal.js @@ -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 ( + closeModal(RECORDING_MODAL)} + actions={[ + , + , + ]} + > + + {!enableRecording ? ( + + 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. + + ) : ( +

+ Recording type enabled: {enableRecording} +

+ )} + + {recordingStartedDate && ( +

Recording started: {recordingStartedDate.toString()}

+ )} + + {enableRecording === RECORDING_TYPE_CLOUD && ( + <> +
+ +

+ Cloud recordings can be accessed via the Daily dashboard under the + "Recordings" section. +

+ + )} +
+
+ ); +}; + +export default RecordingModal; diff --git a/dailyjs/recording/components/RecordingModal/index.js b/dailyjs/recording/components/RecordingModal/index.js new file mode 100644 index 0000000..0a0deca --- /dev/null +++ b/dailyjs/recording/components/RecordingModal/index.js @@ -0,0 +1,3 @@ +export { RecordingModal as default } from './RecordingModal'; +export { RecordingModal } from './RecordingModal'; +export { RECORDING_MODAL } from './RecordingModal'; diff --git a/dailyjs/recording/components/Tray/Tray.js b/dailyjs/recording/components/Tray/Tray.js new file mode 100644 index 0000000..c26f253 --- /dev/null +++ b/dailyjs/recording/components/Tray/Tray.js @@ -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 ( + <> + openModal(RECORDING_MODAL)} + > + + + + ); +}; + +export default Tray; diff --git a/dailyjs/recording/components/Tray/index.js b/dailyjs/recording/components/Tray/index.js new file mode 100644 index 0000000..100bcc8 --- /dev/null +++ b/dailyjs/recording/components/Tray/index.js @@ -0,0 +1 @@ +export { Tray as default } from './Tray'; diff --git a/dailyjs/recording/contexts/RecordingProvider.js b/dailyjs/recording/contexts/RecordingProvider.js new file mode 100644 index 0000000..e56a3f0 --- /dev/null +++ b/dailyjs/recording/contexts/RecordingProvider.js @@ -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 ( + + {children} + + ); +}; + +RecordingProvider.propTypes = { + children: PropTypes.node, +}; + +export const useRecording = () => useContext(RecordingContext); diff --git a/dailyjs/recording/env.example b/dailyjs/recording/env.example new file mode 100644 index 0000000..5ab7e03 --- /dev/null +++ b/dailyjs/recording/env.example @@ -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 diff --git a/dailyjs/recording/image.png b/dailyjs/recording/image.png new file mode 100644 index 0000000..b4af7c6 Binary files /dev/null and b/dailyjs/recording/image.png differ diff --git a/dailyjs/recording/next.config.js b/dailyjs/recording/next.config.js new file mode 100644 index 0000000..9a0a6ee --- /dev/null +++ b/dailyjs/recording/next.config.js @@ -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, + }, +}); diff --git a/dailyjs/recording/package.json b/dailyjs/recording/package.json new file mode 100644 index 0000000..48a1257 --- /dev/null +++ b/dailyjs/recording/package.json @@ -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" + } +} diff --git a/dailyjs/recording/pages/_app.js b/dailyjs/recording/pages/_app.js new file mode 100644 index 0000000..df733a8 --- /dev/null +++ b/dailyjs/recording/pages/_app.js @@ -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 = ; +App.customTrayComponent = ; + +export default App; diff --git a/dailyjs/recording/pages/api b/dailyjs/recording/pages/api new file mode 120000 index 0000000..999f604 --- /dev/null +++ b/dailyjs/recording/pages/api @@ -0,0 +1 @@ +../../basic-call/pages/api \ No newline at end of file diff --git a/dailyjs/recording/pages/index.js b/dailyjs/recording/pages/index.js new file mode 100644 index 0000000..2668138 --- /dev/null +++ b/dailyjs/recording/pages/index.js @@ -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; diff --git a/dailyjs/recording/public b/dailyjs/recording/public new file mode 120000 index 0000000..33a6e67 --- /dev/null +++ b/dailyjs/recording/public @@ -0,0 +1 @@ +../basic-call/public \ No newline at end of file diff --git a/dailyjs/shared/components/Button/Button.js b/dailyjs/shared/components/Button/Button.js index d705ef6..ff36a25 100644 --- a/dailyjs/shared/components/Button/Button.js +++ b/dailyjs/shared/components/Button/Button.js @@ -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; diff --git a/dailyjs/shared/components/Tray/Tray.js b/dailyjs/shared/components/Tray/Tray.js index 42f4f7e..c7b8481 100644 --- a/dailyjs/shared/components/Tray/Tray.js +++ b/dailyjs/shared/components/Tray/Tray.js @@ -9,11 +9,17 @@ export const TrayButton = ({ onClick, bubble = false, orange = false, + disabled = false, }) => { const cx = classNames('tray-button', { orange, bubble }); return (
- {label} @@ -57,6 +63,7 @@ TrayButton.propTypes = { orange: PropTypes.bool, bubble: PropTypes.bool, label: PropTypes.string.isRequired, + disabled: PropTypes.bool, }; export const Tray = ({ children }) => ( diff --git a/dailyjs/shared/contexts/CallProvider.js b/dailyjs/shared/contexts/CallProvider.js index 37bacf3..848069b 100644 --- a/dailyjs/shared/contexts/CallProvider.js +++ b/dailyjs/shared/contexts/CallProvider.js @@ -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, }} > diff --git a/dailyjs/shared/contexts/UIStateProvider.js b/dailyjs/shared/contexts/UIStateProvider.js index 2aab79b..b5ff8ca 100644 --- a/dailyjs/shared/contexts/UIStateProvider.js +++ b/dailyjs/shared/contexts/UIStateProvider.js @@ -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, diff --git a/dailyjs/shared/icons/record-md.svg b/dailyjs/shared/icons/record-md.svg new file mode 100644 index 0000000..e4fdad5 --- /dev/null +++ b/dailyjs/shared/icons/record-md.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dailyjs/shared/icons/star-md.svg b/dailyjs/shared/icons/star-md.svg new file mode 100644 index 0000000..68c299b --- /dev/null +++ b/dailyjs/shared/icons/star-md.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dailyjs/shared/lib/demoProps.js b/dailyjs/shared/lib/demoProps.js index 84abf7c..ba814bb 100644 --- a/dailyjs/shared/lib/demoProps.js +++ b/dailyjs/shared/lib/demoProps.js @@ -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, }; } diff --git a/dailyjs/shared/package.json b/dailyjs/shared/package.json index 18a4587..c8383c3 100644 --- a/dailyjs/shared/package.json +++ b/dailyjs/shared/package.json @@ -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", diff --git a/dailyjs/text-chat/env.example b/dailyjs/text-chat/env.example new file mode 100644 index 0000000..5ab7e03 --- /dev/null +++ b/dailyjs/text-chat/env.example @@ -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 diff --git a/yarn.lock b/yarn.lock index 7a0b84b..cbf8486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==