import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; import { IconContext } from 'react-icons'; import { FaCheckCircle } from 'react-icons/fa'; import { MdBackspace } from 'react-icons/md'; import { Upload } from '../../../../server/SharedMediaTypes'; import { returnFalse, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils'; import { Networking } from '../../../Network'; import { Presentation, TrackMovements } from '../../../util/TrackMovements'; import { ProgressBar } from './ProgressBar'; import './RecordingView.scss'; export interface MediaSegment { videoChunks: Blob[]; endTime: number; startTime: number; presentation?: Presentation; } interface IRecordingViewProps { setResult: (info: Upload.AccessPathInfo, presentation?: Presentation) => void; id: string; getControls: (record: () => void, pause: () => void, finish: () => void) => void; forceTrackScreen: boolean; } const MAXTIME = 100000; const iconVals = { color: '#cc1c08', className: 'video-edit-buttons' }; export function RecordingView(props: IRecordingViewProps) { const [recording, setRecording] = useState(false); const recordingTimerRef = useRef(0); const [recordingTimer, setRecordingTimer] = useState(0); // unit is 0.01 second const [progress, setProgress] = useState(0); // acts as a "refresh state" to tell progressBar when to undo const [doUndo, setDoUndo] = useState(false); // whether an undo can occur or not const [canUndo, setCanUndo] = useState(false); const [videos, setVideos] = useState([]); const [orderVideos, setOrderVideos] = useState(false); const videoRecorder = useRef(null); const videoElementRef = useRef(null); const [finished, setFinished] = useState(false); const [trackScreen, setTrackScreen] = useState(false); const DEFAULT_MEDIA_CONSTRAINTS = { video: { width: 1280, height: 720, }, audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 44100, }, }; useEffect(() => { if (finished) { // make the total presentation that'll match the concatted video const concatPres = (trackScreen || props.forceTrackScreen) && TrackMovements.Instance.concatPresentations(videos.map(v => v.presentation as Presentation)); // this async function uses the server to create the concatted video and then sets the result to it's accessPaths (async () => { const videoFiles = videos.map((vid, i) => new File(vid.videoChunks, `segvideo${i}.mkv`, { type: vid.videoChunks[0].type, lastModified: Date.now() })); // upload the segments to the server and get their server access paths const serverPaths: string[] = (await Networking.UploadFilesToServer(videoFiles.map(file => ({ file })))).map(res => (res.result instanceof Error ? '' : res.result.accessPaths.agnostic.server)); // concat the segments together using post call const result = (await Networking.PostToServer('/concatVideos', serverPaths)) as Upload.AccessPathInfo | Error; !(result instanceof Error) ? props.setResult(result, concatPres || undefined) : console.error('video conversion failed'); })(); } }, [videos]); // this will call upon the progress bar to edit videos to be in the correct order useEffect(() => { finished && setOrderVideos(true); }, [finished]); // check if the browser supports media devices on first load useEffect(() => { if (!navigator.mediaDevices) alert('This browser does not support getUserMedia.'); }, []); useEffect(() => { let interval: null | NodeJS.Timeout = null; if (recording) { interval = setInterval(() => { setRecordingTimer(unit => unit + 1); }, 10); } else if (!recording && recordingTimer !== 0) { interval && clearInterval(interval); } return interval ? () => clearInterval(interval!) : undefined; }, [recording]); const setVideoProgressHelper = (curProgrss: number) => { const newProgress = (curProgrss / MAXTIME) * 100; setProgress(newProgress); }; useEffect(() => { setVideoProgressHelper(recordingTimer); recordingTimerRef.current = recordingTimer; }, [recordingTimer]); const startShowingStream = async (mediaConstraints = DEFAULT_MEDIA_CONSTRAINTS) => { const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints); videoElementRef.current!.src = ''; videoElementRef.current!.srcObject = stream; videoElementRef.current!.muted = true; return stream; }; const record = async () => { // don't need to start a new stream every time we start recording a new segment if (!videoRecorder.current) videoRecorder.current = new MediaRecorder(await startShowingStream()); // temporary chunks of video let videoChunks: Blob[] = []; videoRecorder.current.ondataavailable = (event: BlobEvent) => { if (event.data.size > 0) videoChunks.push(event.data); }; videoRecorder.current.onstart = () => { setRecording(true); // start the recording api when the video recorder starts (trackScreen || props.forceTrackScreen) && TrackMovements.Instance.start(); }; videoRecorder.current.onstop = () => { // if we have a last portion if (videoChunks.length > 1) { // append the current portion to the video pieces const nextVideo = { videoChunks, endTime: recordingTimerRef.current, startTime: videos?.lastElement()?.endTime || 0, }; // depending on if a presenation exists, add it to the video const presentation = TrackMovements.Instance.yieldPresentation(); setVideos(theVideos => [...theVideos, presentation != null && (trackScreen || props.forceTrackScreen) ? { ...nextVideo, presentation } : nextVideo]); } // reset the temporary chunks videoChunks = []; setRecording(false); }; videoRecorder.current.start(200); }; // if this is called, then we're done recording all the segments const finish = () => { // call stop on the video recorder if active videoRecorder.current?.state !== 'inactive' && videoRecorder.current?.stop(); // end the streams (audio/video) to remove recording icon const stream = videoElementRef.current!.srcObject; stream instanceof MediaStream && stream.getTracks().forEach(track => track.stop()); // finish/clear the recoringApi TrackMovements.Instance.finish(); // this will call upon progessbar to update videos to be in the correct order setFinished(true); }; const pause = () => { // if recording, then this is just a new segment videoRecorder.current?.state === 'recording' && videoRecorder.current.stop(); }; const start = (e: React.PointerEvent) => { setupMoveUpEvents( {}, e, returnTrue, returnFalse, () => { // start recording if not already recording if (!videoRecorder.current || videoRecorder.current.state === 'inactive') record(); return true; // cancels propagation to documentView to avoid selecting it. }, false, false ); }; const undoPrevious = (e: React.PointerEvent) => { e.stopPropagation(); setDoUndo(prev => !prev); }; const millisecondToMinuteSecond = (milliseconds: number) => { const toTwoDigit = (digit: number) => (String(digit).length === 1 ? '0' + digit : digit); const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); return toTwoDigit(minutes) + ' : ' + toTwoDigit(seconds); }; useEffect(() => { props.getControls(record, pause, finish); }, []); const iconUndoVals = React.useMemo(() => ({ color: 'grey', className: 'video-edit-buttons', style: { display: canUndo ? 'inherit' : 'none' } }), []); return (
); }