From 99ea4f6504151b9820e9d779d2caec9b649fc291 Mon Sep 17 00:00:00 2001 From: harshithpabbati Date: Thu, 30 Dec 2021 14:12:02 +0530 Subject: [PATCH] Add recording option to the demo --- custom/fitness-demo/components/App/App.js | 37 +- .../components/Modals/RecordingModal.js | 145 ++++++++ custom/fitness-demo/components/Tray/Record.js | 45 +++ custom/fitness-demo/components/Tray/index.js | 2 + .../contexts/RecordingProvider.js | 337 ++++++++++++++++++ custom/fitness-demo/pages/_app.js | 3 +- 6 files changed, 551 insertions(+), 18 deletions(-) create mode 100644 custom/fitness-demo/components/Modals/RecordingModal.js create mode 100644 custom/fitness-demo/components/Tray/Record.js create mode 100644 custom/fitness-demo/contexts/RecordingProvider.js diff --git a/custom/fitness-demo/components/App/App.js b/custom/fitness-demo/components/App/App.js index 4fd540d..15ba19d 100644 --- a/custom/fitness-demo/components/App/App.js +++ b/custom/fitness-demo/components/App/App.js @@ -5,6 +5,7 @@ import { useCallUI } from '@custom/shared/hooks/useCallUI'; import PropTypes from 'prop-types'; import { ChatProvider } from '../../contexts/ChatProvider'; +import { RecordingProvider } from '../../contexts/RecordingProvider'; import Room from '../Call/Room'; import { Asides } from './Asides'; import { Modals } from './Modals'; @@ -23,23 +24,25 @@ export const App = ({ customComponentForState }) => { () => ( <> - {roomExp && } -
- {componentForState()} - - - -
+ + {roomExp && } +
+ {componentForState()} + + + +
+
), diff --git a/custom/fitness-demo/components/Modals/RecordingModal.js b/custom/fitness-demo/components/Modals/RecordingModal.js new file mode 100644 index 0000000..ff52af6 --- /dev/null +++ b/custom/fitness-demo/components/Modals/RecordingModal.js @@ -0,0 +1,145 @@ +import React, { useEffect } from 'react'; +import Button from '@custom/shared/components/Button'; +import { CardBody } from '@custom/shared/components/Card'; +import Modal from '@custom/shared/components/Modal'; +import Well from '@custom/shared/components/Well'; +import { useCallState } from '@custom/shared/contexts/CallProvider'; +import { useUIState } from '@custom/shared/contexts/UIStateProvider'; +import { + RECORDING_COUNTDOWN_1, + RECORDING_COUNTDOWN_2, + RECORDING_COUNTDOWN_3, + RECORDING_IDLE, + RECORDING_RECORDING, + RECORDING_SAVED, + RECORDING_TYPE_CLOUD, + RECORDING_TYPE_CLOUD_BETA, + RECORDING_TYPE_RTP_TRACKS, + 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 enable recording when creating the room or via + the Daily dashboard. + + ) : ( +

+ Recording type enabled: {enableRecording} +

+ )} + + {recordingStartedDate && ( +

Recording started: {recordingStartedDate.toString()}

+ )} + + {[RECORDING_TYPE_CLOUD, RECORDING_TYPE_CLOUD_BETA].includes( + enableRecording + ) && ( + <> +
+ +

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

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

+ rtp-tracks recordings can be accessed via the Daily API. See the{' '} + + Daily recording guide + {' '} + for details. +

+ + )} +
+
+ ); +}; + +export default RecordingModal; \ No newline at end of file diff --git a/custom/fitness-demo/components/Tray/Record.js b/custom/fitness-demo/components/Tray/Record.js new file mode 100644 index 0000000..b4ac465 --- /dev/null +++ b/custom/fitness-demo/components/Tray/Record.js @@ -0,0 +1,45 @@ +import React, { useEffect } from 'react'; + +import { TrayButton } from '@custom/shared/components/Tray'; +import { useUIState } from '@custom/shared/contexts/UIStateProvider'; +import { ReactComponent as IconRecord } from '@custom/shared/icons/record-md.svg'; + +import { + RECORDING_ERROR, + RECORDING_RECORDING, + RECORDING_SAVED, + RECORDING_UPLOADING, + useRecording, +} from '../../contexts/RecordingProvider'; +import { RECORDING_MODAL } from '../Modals/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; \ No newline at end of file diff --git a/custom/fitness-demo/components/Tray/index.js b/custom/fitness-demo/components/Tray/index.js index 01d94c2..40271c8 100644 --- a/custom/fitness-demo/components/Tray/index.js +++ b/custom/fitness-demo/components/Tray/index.js @@ -1,5 +1,6 @@ import React from 'react'; import ChatTray from './Chat'; +import RecordTray from './Record'; import ScreenShareTray from './ScreenShare'; export const Tray = () => { @@ -7,6 +8,7 @@ export const Tray = () => { <> + ); }; diff --git a/custom/fitness-demo/contexts/RecordingProvider.js b/custom/fitness-demo/contexts/RecordingProvider.js new file mode 100644 index 0000000..dd1b7e1 --- /dev/null +++ b/custom/fitness-demo/contexts/RecordingProvider.js @@ -0,0 +1,337 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; + +import { useCallState } from '@custom/shared/contexts/CallProvider'; +import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider'; +import { useUIState } from '@custom/shared/contexts/UIStateProvider'; +import { + CALL_STATE_REDIRECTING, + CALL_STATE_JOINED, +} from '@custom/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_CLOUD_BETA = 'cloud-beta'; +export const RECORDING_TYPE_LOCAL = 'local'; +export const RECORDING_TYPE_OUTPUT_BYTE_STREAM = 'output-byte-stream'; +export const RECORDING_TYPE_RTP_TRACKS = 'rtp-tracks'; + +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; + } + }; + + // The 'recording-data' event is emitted when an output-byte-stream recording has started + // When the event emits, start writing data to the stream created in handleRecordingStarted() + const handleRecordingData = async (ev) => { + try { + console.log('got data', ev); + await window.writer.write(ev.data); + if (ev.finished) { + console.log('closing!'); + window.writer.close(); + } + } catch (e) { + console.error(e); + } + }; + + callObject.on('app-message', handleAppMessage); + callObject.on('recording-data', handleRecordingData); + + return () => { + callObject.off('app-message', handleAppMessage); + callObject.off('recording-data', handleRecordingData); + }; + }, [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) => { + console.log('RECORDING'); + console.log(event); + + if (recordingState === RECORDING_RECORDING) return; + setRecordingState(RECORDING_RECORDING); + if (event.local) { + // Recording started locally, either through UI or programmatically + setIsRecordingLocally(true); + if (!recordingStartedDate) setRecordingStartedDate(new Date()); + // If an output-byte-stream recording has started, create a new data stream that can be piped to a third-party (in this case a file) + if (event.type === 'output-byte-stream') { + const { readable, writable } = new TransformStream({ + transform: (chunk, ctrl) => { + chunk.arrayBuffer().then((b) => ctrl.enqueue(new Uint8Array(b))); + }, + }); + window.writer = writable.getWriter(); + readable.pipeTo(window.streamSaver.createWriteStream('test-vid.mp4')); + } + } + }, + [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 = (event) => { + console.log(event); + 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: + case RECORDING_TYPE_OUTPUT_BYTE_STREAM: + setRecordingState(RECORDING_SAVED); + setIsRecordingLocally(false); + break; + case RECORDING_TYPE_CLOUD: + case RECORDING_TYPE_CLOUD_BETA: + case RECORDING_TYPE_RTP_TRACKS: + setRecordingState(RECORDING_UPLOADING); + setRecordingState(RECORDING_SAVED); + 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); \ No newline at end of file diff --git a/custom/fitness-demo/pages/_app.js b/custom/fitness-demo/pages/_app.js index 0cf0087..df5330e 100644 --- a/custom/fitness-demo/pages/_app.js +++ b/custom/fitness-demo/pages/_app.js @@ -4,6 +4,7 @@ import Head from 'next/head'; import PropTypes from 'prop-types'; import { App as CustomApp } from '../components/App/App'; import ChatAside from '../components/Call/ChatAside'; +import RecordingModal from '../components/Modals/RecordingModal'; import Tray from '../components/Tray'; function App({ Component, pageProps }) { @@ -35,7 +36,7 @@ App.propTypes = { }; App.asides = [ChatAside]; -App.modals = []; +App.modals = [RecordingModal]; App.customTrayComponent = ; App.customAppComponent = ;