Add recording option to the demo

This commit is contained in:
harshithpabbati 2021-12-30 14:12:02 +05:30
parent 22b4afbb34
commit 99ea4f6504
6 changed files with 551 additions and 18 deletions

View File

@ -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 }) => {
() => (
<>
<ChatProvider>
{roomExp && <ExpiryTimer expiry={roomExp} />}
<div className="app">
{componentForState()}
<Modals />
<Asides />
<style jsx>{`
color: white;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loader {
margin: 0 auto;
}
`}</style>
</div>
<RecordingProvider>
{roomExp && <ExpiryTimer expiry={roomExp} />}
<div className="app">
{componentForState()}
<Modals />
<Asides />
<style jsx>{`
color: white;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loader {
margin: 0 auto;
}
`}</style>
</div>
</RecordingProvider>
</ChatProvider>
</>
),

View File

@ -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 (
<Modal
title="Recording"
isOpen={currentModals[RECORDING_MODAL]}
onClose={() => closeModal(RECORDING_MODAL)}
actions={[
<Button key="close" fullWidth variant="outline">
Close
</Button>,
<Button
fullWidth
disabled={!disabled}
key="record"
onClick={() => handleRecordingClick()}
>
{renderButtonLabel()}
</Button>,
]}
>
<CardBody>
{!enableRecording ? (
<Well variant="error">
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.
</Well>
) : (
<p>
Recording type enabled: <strong>{enableRecording}</strong>
</p>
)}
{recordingStartedDate && (
<p>Recording started: {recordingStartedDate.toString()}</p>
)}
{[RECORDING_TYPE_CLOUD, RECORDING_TYPE_CLOUD_BETA].includes(
enableRecording
) && (
<>
<hr />
<p>
Cloud recordings can be accessed via the Daily dashboard under the
&quot;Recordings&quot; section.
</p>
</>
)}
{enableRecording === RECORDING_TYPE_RTP_TRACKS && (
<>
<hr />
<p>
rtp-tracks recordings can be accessed via the Daily API. See the{' '}
<a
href="https://docs.daily.co/guides/recording-calls-with-the-daily-api#retrieve-individual-tracks-from-rtp-tracks-recordings"
noreferrer
target="_blank"
rel="noreferrer"
>
Daily recording guide
</a>{' '}
for details.
</p>
</>
)}
</CardBody>
</Modal>
);
};
export default RecordingModal;

View File

@ -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 (
<TrayButton
label={isRecording ? 'Stop' : 'Record'}
orange={isRecording}
onClick={() => openModal(RECORDING_MODAL)}
>
<IconRecord />
</TrayButton>
);
};
export default Tray;

View File

@ -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 = () => {
<>
<ChatTray />
<ScreenShareTray />
<RecordTray />
</>
);
};

View File

@ -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 (
<RecordingContext.Provider
value={{
isRecordingLocally,
recordingStartedDate,
recordingState,
startRecordingWithCountdown,
stopRecording,
}}
>
{children}
</RecordingContext.Provider>
);
};
RecordingProvider.propTypes = {
children: PropTypes.node,
};
export const useRecording = () => useContext(RecordingContext);

View File

@ -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 = <Tray />;
App.customAppComponent = <CustomApp />;