diff options
Diffstat (limited to 'src/client/views')
-rw-r--r-- | src/client/views/Main.tsx | 6 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx | 13 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.scss | 105 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.tsx | 328 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingBox.tsx | 79 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.scss | 33 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.tsx | 223 | ||||
-rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 33 | ||||
-rw-r--r-- | src/client/views/nodes/trails/PresElementBox.tsx | 4 |
9 files changed, 577 insertions, 247 deletions
diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 49c2dcf34..acc74e914 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -8,7 +8,8 @@ import { AssignAllExtensions } from "../../extensions/General/Extensions"; import { Docs } from "../documents/Documents"; import { CurrentUserUtils } from "../util/CurrentUserUtils"; import { LinkManager } from "../util/LinkManager"; -import { RecordingApi } from "../util/RecordingApi"; +import { ReplayMovements } from '../util/ReplayMovements'; +import { TrackMovements } from "../util/TrackMovements"; import { CollectionView } from "./collections/CollectionView"; import { DashboardView } from './DashboardView'; import { MainView } from "./MainView"; @@ -38,6 +39,7 @@ AssignAllExtensions(); const expires = "expires=" + d.toUTCString(); document.cookie = `loadtime=${loading};${expires};path=/`; new LinkManager(); - new RecordingApi; + new TrackMovements(); + new ReplayMovements(); ReactDOM.render(<MainView />, document.getElementById('root')); })();
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 542b1fce1..1320785a9 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -26,7 +26,6 @@ import { DragManager, dropActionType } from "../../../util/DragManager"; import { HistoryUtil } from "../../../util/History"; import { InteractionUtils } from "../../../util/InteractionUtils"; import { LinkManager } from "../../../util/LinkManager"; -import { RecordingApi } from "../../../util/RecordingApi"; import { ScriptingGlobals } from "../../../util/ScriptingGlobals"; import { SearchUtil } from "../../../util/SearchUtil"; import { SelectionManager } from "../../../util/SelectionManager"; @@ -59,6 +58,7 @@ import "./CollectionFreeFormView.scss"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); import e = require("connect-flash"); +import { ReplayMovements } from "../../../util/ReplayMovements"; export type collectionFreeformViewProps = { @@ -998,15 +998,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action setPan(panX: number, panY: number, panTime: number = 0, clamp: boolean = false) { - // set the current respective FFview to the tab being panned. - (Doc.UserDoc()?.presentationMode === 'recording') && RecordingApi.Instance.setRecordingFFView(this); - // TODO: make this based off the specific recording FFView - (Doc.UserDoc()?.presentationMode === 'none') && RecordingApi.Instance.setPlayFFView(this); - if (Doc.UserDoc()?.presentationMode === 'watching') { - RecordingApi.Instance.pauseVideoAndMovements(); - Doc.UserDoc().presentationMode = 'none'; - // RecordingApi.Instance.pauseMovements() - } + // this is the easiest way to do this -> will talk with Bob about using mobx to do this to remove this line of code. + if (Doc.UserDoc()?.presentationMode === 'watching') ReplayMovements.Instance.pauseFromInteraction(); if (!this.isAnnotationOverlay && clamp) { // this section wraps the pan position, horizontally and/or vertically whenever the content is panned out of the viewing bounds diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss index a493b0b89..28ad25ffa 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.scss +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -1,11 +1,19 @@ .progressbar { + touch-action: none; + vertical-align: middle; + text-align: center; + + align-items: center; + cursor: default; + + position: absolute; display: flex; justify-content: flex-start; - bottom: 10px; - width: 80%; - height: 5px; + bottom: 2px; + width: 99%; + height: 30px; background-color: gray; &.done { @@ -23,4 +31,93 @@ z-index: 3; pointer-events: none; } -}
\ No newline at end of file +} + +.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 index 82d5e1f04..1bb2b7c84 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.tsx +++ b/src/client/views/nodes/RecordingBox/ProgressBar.tsx @@ -1,45 +1,301 @@ import * as React from 'react'; -import { useEffect } from "react" +import { useEffect, useState, useCallback, useRef } from "react" import "./ProgressBar.scss" +import { MediaSegment } from './RecordingView'; interface ProgressBarProps { - progress: number, - marks: number[], + 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`; + } + - // const handleClick = (e: React.MouseEvent) => { - // let progressbar = document.getElementById('progressbar')! - // let bounds = progressbar!.getBoundingClientRect(); - // let x = e.clientX - bounds.left; - // let percent = x / progressbar.clientWidth * 100 - - // for (let i = 0; i < props.marks.length; i++) { - // let start = i == 0 ? 0 : props.marks[i-1]; - // if (percent > start && percent < props.marks[i]) { - // props.playSegment(i) - // // console.log(i) - // // console.log(percent) - // // console.log(props.marks[i]) - // break - // } - // } - // } - - return ( - <div className="progressbar" id="progressbar"> - <div - className="progressbar done" - style={{ width: `${props.progress}%` }} - // onClick={handleClick} - ></div> - {props.marks.map((mark) => { - return <div - className="progressbar mark" - style={{ width: `${mark}%` }} - ></div> - })} - </div> - ) + 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 index 10393624b..0ff7c4292 100644 --- a/src/client/views/nodes/RecordingBox/RecordingBox.tsx +++ b/src/client/views/nodes/RecordingBox/RecordingBox.tsx @@ -8,54 +8,51 @@ import { FieldView } from "../FieldView"; import { VideoBox } from "../VideoBox"; import { RecordingView } from './RecordingView'; import { DocumentType } from "../../../documents/DocumentTypes"; -import { RecordingApi } from "../../../util/RecordingApi"; -import { Doc, FieldsSym } from "../../../../fields/Doc"; +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); } + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecordingBox, fieldKey); } - private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); - constructor(props: any) { + constructor(props: any) { super(props); - } - - componentDidMount() { - console.log("set native width and height") - Doc.SetNativeWidth(this.dataDoc, 1280); - Doc.SetNativeHeight(this.dataDoc, 720); - } - - @observable result: Upload.FileInformation | undefined = undefined - @observable videoDuration: number | undefined = undefined - - @action - setVideoDuration = (duration: number) => { - this.videoDuration = duration - } - - @action - setResult = (info: Upload.FileInformation, trackScreen: boolean) => { - 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.agnostic.client); - this.dataDoc[this.fieldKey + "-recorded"] = true; - // stringify the presenation and store it - if (trackScreen) { - this.dataDoc[this.fieldKey + "-presentation"] = JSON.stringify(RecordingApi.Instance.clear()); - } - } - - render() { - return <div className="recordingBox" ref={this._ref}> - {!this.result && <RecordingView setResult={this.setResult} setDuration={this.setVideoDuration} id={Doc.GetProto(this.rootDoc)[Id]} />} - </div>; - } + } + + 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 index 9b2f6d070..2e6f6bc26 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.scss +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -18,13 +18,13 @@ button { width: 100%; // display: flex; pointer-events: all; - background-color: grey; + background-color: black; } .video-wrapper { // max-width: 600px; // max-width: 700px; - position: relative; + // position: relative; display: flex; justify-content: center; // overflow: hidden; @@ -33,7 +33,7 @@ button { } .video-wrapper:hover .controls { - bottom: 30px; + bottom: 34.5px; transform: translateY(0%); opacity: 100%; } @@ -43,8 +43,8 @@ button { align-items: center; justify-content: space-evenly; position: absolute; - padding: 14px; - width: 100%; + // padding: 14px; + //width: 100%; max-width: 500px; // max-height: 20%; flex-wrap: wrap; @@ -56,7 +56,14 @@ button { // transform: translateY(150%); transition: all 0.3s ease-in-out; // opacity: 0%; - bottom: 30px; + bottom: 34.5px; + height: 60px; + right: 2px; + // bottom: -150px; +} + +.controls:active { + bottom: 40px; // bottom: -150px; } @@ -127,9 +134,8 @@ button { .controls-inner-container { display: flex; flex-direction: row; - justify-content: center; - width: 100%; - + align-content: center; + position: relative; } .record-button-wrapper { @@ -180,14 +186,14 @@ button { height: 100%; display: flex; flex-direction: row; - align-items: center; - position: absolute; + align-content: center; + position: relative; top: 0; bottom: 0; &.video-edit-wrapper { - right: 50% - 15; + // right: 50% - 15; .track-screen { font-weight: 200; @@ -197,10 +203,11 @@ button { &.track-screen-wrapper { - right: 50% - 30; + // right: 50% - 30; .track-screen { font-weight: 200; + color: aqua; } } diff --git a/src/client/views/nodes/RecordingBox/RecordingView.tsx b/src/client/views/nodes/RecordingBox/RecordingView.tsx index b95335792..83ed6914e 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.tsx +++ b/src/client/views/nodes/RecordingBox/RecordingView.tsx @@ -1,23 +1,24 @@ import * as React from 'react'; import "./RecordingView.scss"; -import { ReactElement, useCallback, useEffect, useRef, useState } from "react"; +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'; -import { RecordingApi } from '../../../util/RecordingApi'; -import { emptyFunction, returnFalse, returnTrue, setupMoveUpEvents } from '../../../../Utils'; - -interface MediaSegment { +export interface MediaSegment { videoChunks: any[], - endTime: number + endTime: number, + startTime: number, + presentation?: Presentation, } interface IRecordingViewProps { - setResult: (info: Upload.FileInformation, trackScreen: boolean) => void + setResult: (info: Upload.AccessPathInfo, presentation?: Presentation) => void setDuration: (seconds: number) => void id: string } @@ -32,7 +33,13 @@ export function RecordingView(props: IRecordingViewProps) { 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); @@ -45,53 +52,42 @@ export function RecordingView(props: IRecordingViewProps) { video: { width: 1280, height: 720, + }, audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 44100 } - } + }; useEffect(() => { - if (finished) { - props.setDuration(recordingTimer * 100) - let allVideoChunks: any = [] - videos.forEach((vid) => { - console.log(vid.videoChunks) - allVideoChunks = allVideoChunks.concat(vid.videoChunks) - }) - - const videoFile = new File(allVideoChunks, "video.mkv", { type: allVideoChunks[0].type, lastModified: Date.now() }); - - Networking.UploadFilesToServer(videoFile) - .then((data) => { - const result = data[0].result - if (!(result instanceof Error)) { // convert this screenshotBox into normal videoBox - props.setResult(result, trackScreen) - } else { - alert("video conversion failed"); - } - }) - - } + // 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() })); - }, [finished]) + // 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) - useEffect(() => { - // check if the browser supports media devices on first load - if (!navigator.mediaDevices) { - console.log('This browser does not support getUserMedia.') + // 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"); + })(); } - console.log('This device has the correct media devices.') - }, []) + }, [videos]); + // this will call upon the progress bar to edit videos to be in the correct order useEffect(() => { - // get access to the video element on every render - videoElementRef.current = document.getElementById(`video-${props.id}`) as HTMLVideoElement; - }) + 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; @@ -103,121 +99,108 @@ export function RecordingView(props: IRecordingViewProps) { clearInterval(interval); } return () => clearInterval(interval); - }, [recording]) + }, [recording]); useEffect(() => { setVideoProgressHelper(recordingTimer) recordingTimerRef.current = recordingTimer; - }, [recordingTimer]) + }, [recordingTimer]); const setVideoProgressHelper = (progress: number) => { const newProgress = (progress / MAXTIME) * 100; - setProgress(newProgress) + setProgress(newProgress); } + const startShowingStream = async (mediaConstraints = DEFAULT_MEDIA_CONSTRAINTS) => { - const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints) + const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints); - videoElementRef.current!.src = "" - videoElementRef.current!.srcObject = stream - videoElementRef.current!.muted = true + videoElementRef.current!.src = ""; + videoElementRef.current!.srcObject = stream; + videoElementRef.current!.muted = true; - return stream + return stream; } const record = async () => { - const stream = await startShowingStream(); - videoRecorder.current = new MediaRecorder(stream) + // 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 = [] + let videoChunks: any = []; videoRecorder.current.ondataavailable = (event: any) => { - if (event.data.size > 0) { - videoChunks.push(event.data) - } - } + if (event.data.size > 0) videoChunks.push(event.data); + }; videoRecorder.current.onstart = (event: any) => { setRecording(true); - trackScreen && RecordingApi.Instance.start(); - } + // 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 - setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) + 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 = [] + videoChunks = []; setRecording(false); - setFinished(true); - trackScreen && RecordingApi.Instance.pause(); } - // recording paused - videoRecorder.current.onpause = (event: any) => { - // append the current portion to the video pieces - setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) + videoRecorder.current.start(200); + } - // reset the temporary chunks - videoChunks = [] - setRecording(false); - trackScreen && RecordingApi.Instance.pause(); - } - videoRecorder.current.onresume = async (event: any) => { - await startShowingStream(); - setRecording(true); - trackScreen && RecordingApi.Instance.resume(); - } + // if this is called, then we're done recording all the segments + const finish = (e: React.PointerEvent) => { + e.stopPropagation(); - videoRecorder.current.start(200) - } + // 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()); - const stop = () => { - if (videoRecorder.current) { - if (videoRecorder.current.state !== "inactive") { - videoRecorder.current.stop(); - // recorder.current.stream.getTracks().forEach((track: any) => 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 (videoRecorder.current) { - if (videoRecorder.current.state === "recording") { - videoRecorder.current.pause(); - } - } + const pause = (e: React.PointerEvent) => { + e.stopPropagation(); + // if recording, then this is just a new segment + videoRecorder.current?.state === "recording" && videoRecorder.current.stop(); } - const startOrResume = (e: React.PointerEvent) => { - // the code to start or resume does not get triggered if we start dragging the button + const start = (e: React.PointerEvent) => { setupMoveUpEvents({}, e, returnTrue, returnFalse, e => { - if (!videoRecorder.current || videoRecorder.current.state === "inactive") { - record(); - } else if (videoRecorder.current.state === "paused") { - videoRecorder.current.resume(); - } + // 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 clearPrevious = () => { - const numVideos = videos.length - setRecordingTimer(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) - setVideoProgressHelper(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) - setVideos(videos.filter((_, idx) => idx !== numVideos - 1)); + const undoPrevious = (e: React.PointerEvent) => { + e.stopPropagation(); + setDoUndo(prev => !prev); } - const handleOnTimeUpdate = () => { - if (playing) { - setVideoProgressHelper(videoElementRef.current!.currentTime) - } - }; + const handleOnTimeUpdate = () => { playing && setVideoProgressHelper(videoElementRef.current!.currentTime); }; const millisecondToMinuteSecond = (milliseconds: number) => { const toTwoDigit = (digit: number) => { @@ -234,7 +217,8 @@ export function RecordingView(props: IRecordingViewProps) { <video id={`video-${props.id}`} autoPlay muted - onTimeUpdate={handleOnTimeUpdate} + onTimeUpdate={() => handleOnTimeUpdate()} + ref={videoElementRef} /> <div className="recording-sign"> <span className="dot" /> @@ -246,18 +230,18 @@ export function RecordingView(props: IRecordingViewProps) { <div className="record-button-wrapper"> {recording ? <button className="stop-button" onPointerDown={pause} /> : - <button className="record-button" onPointerDown={startOrResume} /> + <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" }}> - <MdBackspace onClick={clearPrevious} /> - </IconContext.Provider> */} + <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 onClick={stop} /> + <FaCheckCircle onPointerDown={finish} /> </IconContext.Provider> </div> @@ -271,12 +255,17 @@ export function RecordingView(props: IRecordingViewProps) { </div> - <ProgressBar - progress={progress} - marks={videos.map((elt) => elt.endTime / MAXTIME * 100)} - // playSegment={playSegment} - /> </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/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 1b891034f..e833c7e30 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -15,7 +15,6 @@ import { DocumentType } from "../../documents/DocumentTypes"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { DocumentManager } from "../../util/DocumentManager"; -import { RecordingApi } from "../../util/RecordingApi"; import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; import { undoBatch } from "../../util/UndoManager"; @@ -29,8 +28,10 @@ import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { AnchorMenu } from "../pdf/AnchorMenu"; import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; -import { RecordingBox } from "./RecordingBox/RecordingBox"; import "./VideoBox.scss"; +import { Presentation } from "../../util/TrackMovements"; +import { RecordingBox } from "./RecordingBox"; +import { ReplayMovements } from "../../util/ReplayMovements"; const path = require('path'); /** @@ -150,6 +151,10 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } this.player && this.setPlayheadTime(0); document.addEventListener("keydown", this.keyEvents, true); + + if (this.presentation) { + ReplayMovements.Instance.setVideoBox(this); + } } componentWillUnmount() { @@ -157,6 +162,10 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this.Pause(); Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); document.removeEventListener("keydown", this.keyEvents, true); + + if (this.presentation) { + ReplayMovements.Instance.removeVideoBox(); + } } // handles key events, when timeline scrubs fade controls @@ -183,20 +192,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @action public Play = (update: boolean = true) => { if (this._playRegionTimer) return; - // if (Doc.UserDoc().presentationMode === 'watching' && !this._playing) { - // console.log('VideoBox : Play : presentation mode', this._playing); - // return; - // } - - // if presentation isn't null, call followmovements on the recording api - if (this.presentation) { - // console.log("presentation isn't null") - const err = RecordingApi.Instance.playMovements(this.presentation, this.player?.currentTime || 0, this); - err && console.log(err) - } else { - // console.log("presentation is null") - } - this._playing = true; const eleTime = this.player?.currentTime || 0; if (this.timeline) { @@ -238,12 +233,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp // pauses video @action public Pause = (update: boolean = true) => { - if (this.presentation) { - console.log('VideoBox : Pause'); - const err = RecordingApi.Instance.pauseMovements(); - err && console.log(err); - } - this._playing = false; this.removeCurrentlyPlaying(); try { diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index 1a2f4b93f..a4c69f66b 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -361,8 +361,8 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { } else { // if we dont have any recording const recording = Docs.Create.WebCamDocument("", { - _width: 400, _height: 200, - // hideDocumentButtonBar: true, + _width: 384, _height: 216, + hideDocumentButtonBar: true, hideDecorationTitle: true, hideOpenButton: true, // hideDeleteButton: true, |