diff options
| author | ljungster <parkerljung@gmail.com> | 2022-08-09 11:52:07 -0500 |
|---|---|---|
| committer | ljungster <parkerljung@gmail.com> | 2022-08-09 11:52:07 -0500 |
| commit | da3cb00f809a482a9fdf732f6a656fbc467cce27 (patch) | |
| tree | 9eb1fd278bc71d080d71bbfb7e3aec482d35f439 /src/client/views/nodes/RecordingBox | |
| parent | 1638527259a072dfc2ab286bd27bbb1751e8434e (diff) | |
| parent | 26670c8b9eb6e2fd981c3a0997bff5556b60504b (diff) | |
Merge branch 'parker' of https://github.com/brown-dash/Dash-Web into parker
Diffstat (limited to 'src/client/views/nodes/RecordingBox')
| -rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.scss | 123 | ||||
| -rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.tsx | 301 | ||||
| -rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingBox.tsx | 58 | ||||
| -rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.scss | 214 | ||||
| -rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.tsx | 271 | ||||
| -rw-r--r-- | src/client/views/nodes/RecordingBox/index.ts | 2 |
6 files changed, 969 insertions, 0 deletions
diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss new file mode 100644 index 000000000..28ad25ffa --- /dev/null +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -0,0 +1,123 @@ + +.progressbar { + touch-action: none; + vertical-align: middle; + text-align: center; + + align-items: center; + cursor: default; + + + position: absolute; + display: flex; + justify-content: flex-start; + bottom: 2px; + width: 99%; + height: 30px; + background-color: gray; + + &.done { + top: 0; + width: 0px; + height: 5px; + background-color: red; + z-index: 2; + } + + &.mark { + top: 0; + background-color: transparent; + border-right: 2px solid white; + z-index: 3; + pointer-events: none; + } +} + +.progressbar-disabled { + cursor: not-allowed; +} + +.progressbar-dragging { + cursor: grabbing; +} + +// citation: https://codepen.io/_Master_/pen/PRdjmQ +@keyframes blinker { + from {opacity: 1.0;} + to {opacity: 0.0;} +} +.blink { + text-decoration: blink; + animation-name: blinker; + animation-duration: 0.6s; + animation-iteration-count:infinite; + animation-timing-function:ease-in-out; + animation-direction: alternate; +} + +.segment { + border: 3px solid black; + background-color: red; + margin: 1px; + padding: 0; + cursor: pointer; + transition-duration: .5s; + user-select: none; + + vertical-align: middle; + text-align: center; +} + +.segment-expanding { +border-color: red; + background-color: white; + transition-duration: 0s; + opacity: .75; + pointer-events: none; +} + +.segment-expanding:hover { + background-color: inherit; + cursor: not-allowed; +} + +.segment-disabled { + pointer-events: none; + opacity: 0.5; + transition-duration: 0s; + /* Hide the text. */ + text-indent: 100%; + white-space: nowrap; + overflow: hidden; +} + +.segment-hide { + background-color: inherit; + text-align: center; + vertical-align: middle; + user-select: none; +} + +.segment:first-child { + margin-left: 2px; +} +.segment:last-child { + margin-right: 2px; +} + +.segment:hover { + background-color: white; +} + +.segment:hover, .segment-selected { + margin: 0px; + border: 4px solid red; + border-radius: 2px; +} + +.segment-selected { + border: 4px solid #202020; + background-color: red; + opacity: .75; + cursor: grabbing; +} diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.tsx b/src/client/views/nodes/RecordingBox/ProgressBar.tsx new file mode 100644 index 000000000..1bb2b7c84 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/ProgressBar.tsx @@ -0,0 +1,301 @@ +import * as React from 'react'; +import { useEffect, useState, useCallback, useRef } from "react" +import "./ProgressBar.scss" +import { MediaSegment } from './RecordingView'; + +interface ProgressBarProps { + videos: MediaSegment[], + setVideos: React.Dispatch<React.SetStateAction<MediaSegment[]>>, + orderVideos: boolean, + progress: number, + recording: boolean, + doUndo: boolean, + setCanUndo?: React.Dispatch<React.SetStateAction<boolean>>, +} + +interface SegmentBox { + endTime: number, + startTime: number, + order: number, +} +interface CurrentHover { + index: number, + minX: number, + maxX: number +} + +export function ProgressBar(props: ProgressBarProps) { + const progressBarRef = useRef<HTMLDivElement | null>(null) + + // the actual list of JSX elements rendered as segments + const [segments, setSegments] = useState<JSX.Element[]>([]); + // array for the order of video segments + const [ordered, setOrdered] = useState<SegmentBox[]>([]); + + const [undoStack, setUndoStack] = useState<SegmentBox[]>([]); + + // -1 if no segment is currently being dragged around; else, it is the id of that segment over + // NOTE: the id of a segment is its index in the ordered array + const [dragged, setDragged] = useState<number>(-1); + + // length of the time removed from the video, in seconds*100 + const [totalRemovedTime, setTotalRemovedTime] = useState<number>(0); + + // this holds the index of the videoc segment to be removed + const [removed, setRemoved] = useState<number>(-1); + + // update the canUndo props based on undo stack + useEffect(() => props.setCanUndo?.(undoStack.length > 0), [undoStack.length]); + + // useEffect for undo - brings back the most recently deleted segment + useEffect(() => handleUndo(), [props.doUndo]) + const handleUndo = () => { + // get the last element from the undo if it exists + if (undoStack.length === 0) return; + // get and remove the last element from the undo stack + const last = undoStack.lastElement(); + setUndoStack(prevUndo => prevUndo.slice(0, -1)); + + // update the removed time and place element back into ordered + setTotalRemovedTime(prevRemoved => prevRemoved - (last.endTime - last.startTime)); + setOrdered(prevOrdered => [...prevOrdered, last]); + } + + // useEffect for recording changes - changes style to disabled and adds the "expanding-segment" + useEffect(() => { + // get segments segment's html using it's id -> make them appeared disabled (or enabled) + segments.forEach((seg) => document.getElementById(seg.props.id)?.classList.toggle('segment-disabled', props.recording)); + progressBarRef.current?.classList.toggle('progressbar-disabled', props.recording); + + if (props.recording) + setSegments(prevSegments => [...prevSegments, <div key='segment-expanding' id='segment-expanding' className='segment segment-expanding blink' style={{ width: 'fit-content' }}>{props.videos.length + 1}</div>]); + }, [props.recording]) + + + // useEffect that updates the segmentsJSX, which is rendered + // only updated when ordered is updated or if the user is dragging around a segment + useEffect(() => { + const totalTime = props.progress * 1000 - totalRemovedTime; + const segmentsJSX = ordered.map((seg, i) => + <div key={`segment-${i}`} id={`segment-${i}`} className={dragged === i ? 'segment-hide' : 'segment'} style={{ width: `${((seg.endTime - seg.startTime) / totalTime) * 100}%` }}>{seg.order + 1}</div>); + + setSegments(segmentsJSX) + }, [dragged, ordered]); + + // useEffect for dragged - update the cursor to be grabbing while grabbing + useEffect(() => { + progressBarRef.current?.classList.toggle('progressbar-dragging', dragged !== -1); + }, [dragged]); + + // to imporve performance, only want to update the CSS width, not re-render the whole JSXList + useEffect(() => { + if (!props.recording) return + const totalTime = props.progress * 1000 - totalRemovedTime; + let remainingTime = totalTime; + segments.forEach((seg, i) => { + // for the last segment, we need to set that directly + if (i === segments.length - 1) return; + // update remaining time + remainingTime -= (ordered[i].endTime - ordered[i].startTime); + + // update the width for this segment + const htmlId = seg.props.id; + const segmentHtml = document.getElementById(htmlId); + if (segmentHtml) segmentHtml.style.width = `${((ordered[i].endTime - ordered[i].startTime) / totalTime) * 100}%`; + }); + + // update the width of the expanding segment using the remaining time + const segExapandHtml = document.getElementById('segment-expanding'); + if (segExapandHtml) + segExapandHtml.style.width = ordered.length === 0 ? '100%' : `${(remainingTime / totalTime) * 100}%`; + }, [props.progress]); + + // useEffect for props.videos - update the ordered array when a new video is added + useEffect(() => { + // this useEffect fired when the videos are being rearragned to the order + // in this case, do nothing. + if (props.orderVideos) return; + + const order = props.videos.length - 1; + // in this case, a new video is added -> push it onto ordered + if (order >= ordered.length) { + const { endTime, startTime } = props.videos.lastElement(); + setOrdered(prevOrdered => { + return [...prevOrdered, { endTime, startTime, order }]; + }); + } + + // in this case, a video is removed + else if (order < ordered.length) { + console.warn('warning: video removed from parent'); + } + }, [props.videos]); + + // useEffect for props.orderVideos - matched the order array with the videos array before the export + useEffect(() => props.setVideos(vids => ordered.map((seg) => vids[seg.order])), [props.orderVideos]); + + // useEffect for removed - handles logic for removing a segment + useEffect(() => { + if (removed === -1) return; + // update total removed time + setTotalRemovedTime(prevRemoved => prevRemoved + (ordered[removed].endTime - ordered[removed].startTime)); + + // put the element on the undo stack + setUndoStack(prevUndo => [...prevUndo, ordered[removed]]); + // remove the segment from the array + setOrdered(prevOrdered => prevOrdered.filter((seg, i) => i !== removed)); + // reset to default/nullish state + setRemoved(-1); + }, [removed]); + + // returns the new currentHover based on the new index + const updateCurrentHover = (segId: number): CurrentHover | null => { + // get the segId of the segment that will become the new bounding area + const rect = progressBarRef.current?.children[segId].getBoundingClientRect() + if (rect == null) return null + return { + index: segId, + minX: rect.x, + maxX: rect.x + rect.width, + } + } + + // pointerdown event for the progress bar + const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { + // don't move the videobox element + e.stopPropagation(); + + // if recording, do nothing + if (props.recording) return; + + // get the segment the user clicked on to be dragged + const clickedSegment = e.target as HTMLDivElement & EventTarget + + // get the profess bar ro add event listeners + // don't do anything if null + const progressBar = progressBarRef.current + if (progressBar == null || clickedSegment.id === progressBar.id) return + + // if holding shift key, let's remove that segment + if (e.shiftKey) { + const segId = parseInt(clickedSegment.id.split('-')[1]); + setRemoved(segId); + return + } + + // if holding ctrl key and click, let's undo that segment #hiddenfeature lol + if (e.ctrlKey) { + handleUndo(); + return; + } + + // if we're here, the user is dragging a segment around + // let the progress bar capture all the pointer events until the user releases (pointerUp) + const ptrId = e.pointerId; + progressBar.setPointerCapture(ptrId) + + const rect = clickedSegment.getBoundingClientRect() + // id for segment is like 'segment-1' or 'segment-10', + // so this works to get the id + const segId = parseInt(clickedSegment.id.split('-')[1]) + // set the selected segment to be the one dragged + setDragged(segId) + + // this is the logic for storing the lower X bound and upper X bound to know + // whether a swap is needed between two segments + let currentHover: CurrentHover = { + index: segId, + minX: rect.x, + maxX: rect.x + rect.width, + } + + // create the floating segment that tracks the cursor + const detchedSegment = document.createElement("div") + initDeatchSegment(detchedSegment, rect); + + const updateSegmentOrder = (event: PointerEvent): void => { + event.stopPropagation(); + event.preventDefault(); + + // this fixes a bug where pointerup doesn't fire while cursor is upped while being dragged + if (!progressBar.hasPointerCapture(ptrId)) { + placeSegmentandCleanup(); + return; + } + + followCursor(event, detchedSegment); + + const curX = event.clientX; + // handle the left bound + if (curX < currentHover.minX && currentHover.index > 0) { + swapSegments(currentHover.index, currentHover.index - 1) + currentHover = updateCurrentHover(currentHover.index - 1) ?? currentHover + } + // handle the right bound + else if (curX > currentHover.maxX && currentHover.index < segments.length - 1) { + swapSegments(currentHover.index, currentHover.index + 1) + currentHover = updateCurrentHover(currentHover.index + 1) ?? currentHover + } + } + + // handles when the user is done dragging the segment (pointerUp) + const placeSegmentandCleanup = (event?: PointerEvent): void => { + event?.stopPropagation(); + event?.preventDefault(); + // if they put the segment outside of the bounds, remove it + if (event && (event.clientX < 0 || event.clientX > document.body.clientWidth || event.clientY < 0 || event.clientY > document.body.clientHeight)) + setRemoved(currentHover.index); + + // remove the update event listener for pointermove + progressBar.removeEventListener('pointermove', updateSegmentOrder); + // remove the floating segment from the DOM + detchedSegment.remove(); + // dragged is -1 is equiv to nothing being dragged, so the normal state + // so this will place the segment in it's location and update the segment bar + setDragged(-1); + } + + // event listeners that allow the user to drag and release the floating segment + progressBar.addEventListener('pointermove', updateSegmentOrder); + progressBar.addEventListener('pointerup', placeSegmentandCleanup, { once: true }); + } + + const swapSegments = (oldIndex: number, newIndex: number) => { + if (newIndex == null) return; + setOrdered(prevOrdered => { + const temp = { ...prevOrdered[oldIndex] } + prevOrdered[oldIndex] = prevOrdered[newIndex] + prevOrdered[newIndex] = temp + return prevOrdered + }); + // update visually where the segment is hovering over + setDragged(newIndex); + } + + // functions for the floating segment that tracks the cursor while grabbing it + const initDeatchSegment = (dot: HTMLDivElement, rect: DOMRect) => { + dot.classList.add("segment-selected"); + dot.style.transitionDuration = '0s'; + dot.style.position = 'absolute'; + dot.style.zIndex = '999'; + dot.style.width = `${rect.width}px`; + dot.style.height = `${rect.height}px`; + dot.style.left = `${rect.x}px`; + dot.style.top = `${rect.y}px`; + dot.draggable = false; + document.body.append(dot); + } + const followCursor = (event: PointerEvent, dot: HTMLDivElement): void => { + // event.stopPropagation() + const { width, height } = dot.getBoundingClientRect(); + dot.style.left = `${event.clientX - width / 2}px`; + dot.style.top = `${event.clientY - height / 2}px`; + } + + + return ( + <div className="progressbar" id="progressbar" onPointerDown={onPointerDown} ref={progressBarRef}> + {segments} + </div> + ) +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/RecordingBox.tsx b/src/client/views/nodes/RecordingBox/RecordingBox.tsx new file mode 100644 index 000000000..0ff7c4292 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingBox.tsx @@ -0,0 +1,58 @@ +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { VideoField } from "../../../../fields/URLField"; +import { Upload } from "../../../../server/SharedMediaTypes"; +import { ViewBoxBaseComponent } from "../../DocComponent"; +import { FieldView } from "../FieldView"; +import { VideoBox } from "../VideoBox"; +import { RecordingView } from './RecordingView'; +import { DocumentType } from "../../../documents/DocumentTypes"; +import { Presentation } from "../../../util/TrackMovements"; +import { Doc } from "../../../../fields/Doc"; +import { Id } from "../../../../fields/FieldSymbols"; + + +@observer +export class RecordingBox extends ViewBoxBaseComponent() { + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecordingBox, fieldKey); } + + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + + constructor(props: any) { + super(props); + } + + componentDidMount() { + Doc.SetNativeWidth(this.dataDoc, 1280); + Doc.SetNativeHeight(this.dataDoc, 720); + } + + @observable result: Upload.AccessPathInfo | undefined = undefined + @observable videoDuration: number | undefined = undefined + + @action + setVideoDuration = (duration: number) => { + this.videoDuration = duration + } + + @action + setResult = (info: Upload.AccessPathInfo, presentation?: Presentation) => { + this.result = info + this.dataDoc.type = DocumentType.VID; + this.dataDoc[this.fieldKey + "-duration"] = this.videoDuration; + + this.dataDoc.layout = VideoBox.LayoutString(this.fieldKey); + this.dataDoc[this.props.fieldKey] = new VideoField(this.result.accessPaths.client); + this.dataDoc[this.fieldKey + "-recorded"] = true; + // stringify the presentation and store it + presentation?.movements && (this.dataDoc[this.fieldKey + "-presentation"] = JSON.stringify(presentation)); + } + + render() { + return <div className="recordingBox" ref={this._ref}> + {!this.result && <RecordingView setResult={this.setResult} setDuration={this.setVideoDuration} id={this.rootDoc.proto?.[Id] || ''} />} + </div>; + } +} diff --git a/src/client/views/nodes/RecordingBox/RecordingView.scss b/src/client/views/nodes/RecordingBox/RecordingView.scss new file mode 100644 index 000000000..2e6f6bc26 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -0,0 +1,214 @@ +video { + // flex: 100%; + width: 100%; + // min-height: 400px; + //height: auto; + height: 100%; + //display: block; + object-fit: cover; + background-color: black; +} + +button { + margin: 0 .5rem +} + +.recording-container { + height: 100%; + width: 100%; + // display: flex; + pointer-events: all; + background-color: black; +} + +.video-wrapper { + // max-width: 600px; + // max-width: 700px; + // position: relative; + display: flex; + justify-content: center; + // overflow: hidden; + border-radius: 10px; + margin: 0; +} + +.video-wrapper:hover .controls { + bottom: 34.5px; + transform: translateY(0%); + opacity: 100%; +} + +.controls { + display: flex; + align-items: center; + justify-content: space-evenly; + position: absolute; + // padding: 14px; + //width: 100%; + max-width: 500px; + // max-height: 20%; + flex-wrap: wrap; + background: rgba(255, 255, 255, 0.25); + box-shadow: 0 8px 32px 0 rgba(255, 255, 255, 0.1); + backdrop-filter: blur(4px); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.18); + // transform: translateY(150%); + transition: all 0.3s ease-in-out; + // opacity: 0%; + bottom: 34.5px; + height: 60px; + right: 2px; + // bottom: -150px; +} + +.controls:active { + bottom: 40px; + // bottom: -150px; +} + +.actions button { + background: none; + border: none; + outline: none; + cursor: pointer; +} + +.actions button i { + background-color: none; + color: white; + font-size: 30px; +} + + +.velocity { + appearance: none; + background: none; + color: white; + outline: none; + border: none; + text-align: center; + font-size: 16px; +} + +.mute-btn { + background: none; + border: none; + outline: none; + cursor: pointer; +} + +.mute-btn i { + background-color: none; + color: white; + font-size: 20px; +} + +.recording-sign { + height: 20px; + width: auto; + display: flex; + flex-direction: row; + position: absolute; + top: 10px; + right: 15px; + align-items: center; + justify-content: center; + + .timer { + font-size: 15px; + color: white; + margin: 0; + } + + .dot { + height: 15px; + width: 15px; + margin: 5px; + background-color: red; + border-radius: 50%; + display: inline-block; + } +} + +.controls-inner-container { + display: flex; + flex-direction: row; + align-content: center; + position: relative; +} + +.record-button-wrapper { + width: 35px; + height: 35px; + font-size: 0; + background-color: grey; + border: 0px; + border-radius: 35px; + margin: 10px; + display: flex; + justify-content: center; + + .record-button { + background-color: red; + border: 0px; + border-radius: 50%; + height: 80%; + width: 80%; + align-self: center; + margin: 0; + + &:hover { + height: 85%; + width: 85%; + } + } + + .stop-button { + background-color: red; + border: 0px; + border-radius: 10%; + height: 70%; + width: 70%; + align-self: center; + margin: 0; + + + // &:hover { + // width: 40px; + // height: 40px + // } + } + +} + +.options-wrapper { + height: 100%; + display: flex; + flex-direction: row; + align-content: center; + position: relative; + top: 0; + bottom: 0; + + &.video-edit-wrapper { + + // right: 50% - 15; + + .track-screen { + font-weight: 200; + } + + } + + &.track-screen-wrapper { + + // right: 50% - 30; + + .track-screen { + font-weight: 200; + color: aqua; + } + + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/RecordingView.tsx b/src/client/views/nodes/RecordingBox/RecordingView.tsx new file mode 100644 index 000000000..ec5917b9e --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingView.tsx @@ -0,0 +1,271 @@ +import * as React from 'react'; +import "./RecordingView.scss"; +import { useEffect, useRef, useState } from "react"; +import { ProgressBar } from "./ProgressBar" +import { MdBackspace } from 'react-icons/md'; +import { FaCheckCircle } from 'react-icons/fa'; +import { IconContext } from "react-icons"; +import { Networking } from '../../../Network'; +import { Upload } from '../../../../server/SharedMediaTypes'; +import { returnFalse, returnTrue, setupMoveUpEvents } from '../../../../Utils'; +import { Presentation, TrackMovements } from '../../../util/TrackMovements'; + +export interface MediaSegment { + videoChunks: any[], + endTime: number, + startTime: number, + presentation?: Presentation, +} + +interface IRecordingViewProps { + setResult: (info: Upload.AccessPathInfo, presentation?: Presentation) => void + setDuration: (seconds: number) => void + id: string +} + +const MAXTIME = 100000; + +export function RecordingView(props: IRecordingViewProps) { + + const [recording, setRecording] = useState(false); + const recordingTimerRef = useRef<number>(0); + const [recordingTimer, setRecordingTimer] = useState(0); // unit is 0.01 second + const [playing, setPlaying] = useState(false); + 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<MediaSegment[]>([]); + const [orderVideos, setOrderVideos] = useState<boolean>(false); + const videoRecorder = useRef<MediaRecorder | null>(null); + const videoElementRef = useRef<HTMLVideoElement | null>(null); + + const [finished, setFinished] = useState<boolean>(false); + const [trackScreen, setTrackScreen] = useState<boolean>(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 + let concatPres = trackScreen && 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(res => (res.result instanceof Error) ? '' : res.result.accessPaths.agnostic.server) + + // concat the segments together using post call + const result: Upload.AccessPathInfo | Error = await Networking.PostToServer('/concatVideos', serverPaths); + !(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: any = null; + if (recording) { + interval = setInterval(() => { + setRecordingTimer(unit => unit + 1); + }, 10); + } else if (!recording && recordingTimer !== 0) { + clearInterval(interval); + } + return () => clearInterval(interval); + }, [recording]); + + useEffect(() => { + setVideoProgressHelper(recordingTimer) + recordingTimerRef.current = recordingTimer; + }, [recordingTimer]); + + const setVideoProgressHelper = (progress: number) => { + const newProgress = (progress / MAXTIME) * 100; + setProgress(newProgress); + } + + 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: any = []; + + videoRecorder.current.ondataavailable = (event: any) => { + if (event.data.size > 0) videoChunks.push(event.data); + }; + + videoRecorder.current.onstart = (event: any) => { + setRecording(true); + // start the recording api when the video recorder starts + trackScreen && 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(videos => [...videos, (presentation != null && trackScreen) ? { ...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 = (e: React.PointerEvent) => { + e.stopPropagation(); + + // 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 = (e: React.PointerEvent) => { + e.stopPropagation(); + // 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, e => { + // 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 handleOnTimeUpdate = () => { playing && setVideoProgressHelper(videoElementRef.current!.currentTime); }; + + const millisecondToMinuteSecond = (milliseconds: number) => { + const toTwoDigit = (digit: number) => { + return 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); + } + + return ( + <div className="recording-container"> + <div className="video-wrapper"> + <video id={`video-${props.id}`} + autoPlay + muted + onTimeUpdate={() => handleOnTimeUpdate()} + ref={videoElementRef} + /> + <div className="recording-sign"> + <span className="dot" /> + <p className="timer">{millisecondToMinuteSecond(recordingTimer * 10)}</p> + </div> + <div className="controls"> + + <div className="controls-inner-container"> + <div className="record-button-wrapper"> + {recording ? + <button className="stop-button" onPointerDown={pause} /> : + <button className="record-button" onPointerDown={start} /> + } + </div> + + {!recording && (videos.length > 0 ? + + <div className="options-wrapper video-edit-wrapper"> + <IconContext.Provider value={{ color: "grey", className: "video-edit-buttons", style: { display: canUndo ? 'inherit' : 'none' } }}> + <MdBackspace onPointerDown={undoPrevious} /> + </IconContext.Provider> + <IconContext.Provider value={{ color: "#cc1c08", className: "video-edit-buttons" }}> + <FaCheckCircle onPointerDown={finish} /> + </IconContext.Provider> + </div> + + : <div className="options-wrapper track-screen-wrapper"> + <label className="track-screen"> + <input type="checkbox" checked={trackScreen} onChange={(e) => { setTrackScreen(e.target.checked) }} /> + <span className="checkmark"></span> + Track Screen + </label> + </div>)} + + </div> + + </div> + + <ProgressBar + videos={videos} + setVideos={setVideos} + orderVideos={orderVideos} + progress={progress} + recording={recording} + doUndo={doUndo} + setCanUndo={setCanUndo} + /> + </div> + </div>) +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/index.ts b/src/client/views/nodes/RecordingBox/index.ts new file mode 100644 index 000000000..ff21eaed6 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/index.ts @@ -0,0 +1,2 @@ +export * from './RecordingView' +export * from './RecordingBox'
\ No newline at end of file |
