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 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); } }; const handleRecordingUploadCompleted = () => { setRecordingState(RECORDING_SAVED); }; callObject.on('app-message', handleAppMessage); callObject.on('recording-data', handleRecordingData); 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: 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); 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);