diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/util/RecordingApi.ts | 195 | ||||
-rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 150 |
2 files changed, 210 insertions, 135 deletions
diff --git a/src/client/util/RecordingApi.ts b/src/client/util/RecordingApi.ts index ae5431a03..87cb85497 100644 --- a/src/client/util/RecordingApi.ts +++ b/src/client/util/RecordingApi.ts @@ -57,7 +57,7 @@ export class RecordingApi { this.absoluteStart = -1; // used for tracking movements in the view frame - this.recordingFFViews = new Map(); + this.recordingFFViews = null; this.tabChangeDisposeFunc = null; // for now, set playFFView @@ -127,6 +127,10 @@ export class RecordingApi { } public initTabTracker = () => { + if (this.recordingFFViews === null) { + this.recordingFFViews = new Map(); + } + // init the dispose funcs on the page const docList = DocListCast(CollectionDockingView.Instance.props.Document.data); this.updateRecordingFFViewsFromTabs(docList); @@ -142,6 +146,8 @@ export class RecordingApi { } public start = (meta?: Object) => { + this.initTabTracker(); + // update the presentation mode Doc.UserDoc().presentationMode = 'recording'; @@ -190,6 +196,9 @@ export class RecordingApi { this.tabChangeDisposeFunc?.(); // update the presentation mode now that we are done tracking Doc.UserDoc().presentationMode = 'none'; + + this.recordingFFViews = null; + this.tabChangeDisposeFunc = null; } // clear presenation data @@ -250,13 +259,28 @@ export class RecordingApi { private videoBoxDisposeFunc: IReactionDisposer | null = null; private videoBox: VideoBox | null = null; - setVideoBox = (videoBox: VideoBox) => { + setVideoBox = async (videoBox: VideoBox) => { console.log('setVideoBox', videoBox); - if (this.videoBoxDisposeFunc !== null) { console.warn('setVideoBox on already videoBox'); this.videoBoxDisposeFunc(); } + if (videoBox !== null) { console.warn('setVideoBox on already videoBox'); } + if (this.videoBoxDisposeFunc !== null) { console.warn('setVideoBox on already videoBox dispose func'); this.videoBoxDisposeFunc(); } + + + const { presentation } = videoBox; + if (presentation == null) { console.warn('setVideoBox on null videoBox presentation'); return; } + + let docIdtoDoc: Map<string, Doc> = new Map(); + try { + docIdtoDoc = await this.loadPresentation(presentation); + } catch { + console.error('[recordingApi.ts] setVideoBox(): error loading presentation - no replay movements'); + throw 'error loading docs from server'; + } + + this.videoBoxDisposeFunc = reaction(() => ({ playing: videoBox._playing, timeViewed: videoBox.player?.currentTime || 0 }), ({ playing, timeViewed }) => - playing ? this.playMovements(videoBox.presentation, timeViewed) : this.pauseMovements() + playing ? this.playMovements(presentation, docIdtoDoc, timeViewed) : this.pauseMovements() ); this.videoBox = videoBox; } @@ -283,10 +307,81 @@ export class RecordingApi { public _isPlaying = false; - public playMovements = (presentation: Presentation, timeViewed: number = 0, videoBox?: VideoBox): undefined | Error => { - console.log('playMovements', presentation, timeViewed, videoBox); + loadPresentation = async (presentation: Presentation) => { + const { movements } = presentation; + if (movements === null) { + throw '[recordingApi.ts] followMovements() failed: no presentation data'; + } + + // generate a set of all unique docIds + const docIds = new Set<string>(); + for (const {docId} of movements) { + if (!docIds.has(docId)) docIds.add(docId); + } + + const docIdtoDoc = new Map<string, Doc>(); + + let refFields = await DocServer.GetRefFields([...docIds.keys()]); + for (const docId in refFields) { + if (!refFields[docId]) { + throw `one field was undefined`; + } + docIdtoDoc.set(docId, refFields[docId] as Doc); + } + console.log('loadPresentation refFields', refFields, docIdtoDoc); + + return docIdtoDoc; + } + + // returns undefined if the docView isn't open on the screen + getCollectionFFView = (docId: string) => { + const isInView = DocumentManager.Instance.getDocumentViewById(docId); + if (isInView) { return isInView.ComponentView as CollectionFreeFormView; } + } + + // will open the doc in a tab then return the CollectionFFView that holds it + openTab = (docId: string, docIdtoDoc: Map<string, Doc>) => { + const doc = docIdtoDoc.get(docId); + if (doc == undefined) { + console.error(`docIdtoDoc did not contain docId ${docId}`) + return undefined; + } + CollectionDockingView.AddSplit(doc, 'right'); + const docView = DocumentManager.Instance.getDocumentViewById(docId); + return docView?.ComponentView as CollectionFreeFormView; + } + + // helper to replay a movement + private preScale = -1; + zoomAndPan = (movement: Movement, document: CollectionFreeFormView) => { + const { panX, panY, scale } = movement; + (scale !== 0 && this.preScale !== scale) && document.zoomSmoothlyAboutPt([panX, panY], scale, 0); + document.Document._panX = panX; + document.Document._panY = panY; + + this.preScale = scale; + } + + getFirstMovements = (movements: Movement[], timeViewed: number): Map<string, Movement> => { + if (movements === null) return new Map(); + // generate a set of all unique docIds + const docIdtoFirstMove = new Map(); + for (const move of movements) { + const { docId } = move; + if (!docIdtoFirstMove.has(docId)) docIdtoFirstMove.set(docId, move); + } + return docIdtoFirstMove; + } + + endPlayingPresentation = () => { + this.preScale = -1; + RecordingApi.Instance._isPlaying = false; + } + + public playMovements = (presentation: Presentation, docIdtoDoc: Map<string, Doc>, timeViewed: number = 0) => { + console.log('playMovements', presentation, timeViewed, docIdtoDoc); - if (presentation.movements === null) { //|| this.playFFView === null) { + if (presentation.movements === null || presentation.movements.length === 0) { //|| this.playFFView === null) { return new Error('[recordingApi.ts] followMovements() failed: no presentation data') } if (this._isPlaying) return; @@ -294,65 +389,45 @@ export class RecordingApi { this._isPlaying = true; Doc.UserDoc().presentationMode = 'watching'; - // TODO: consider this bug at the end of the clip on seek - // this.videoBox = videoBox || null; - // only get the movements that are remaining in the video time left const filteredMovements = presentation.movements.filter(movement => movement.time > timeViewed * 1000) - // helper to replay a movement - let preScale = -1; - const zoomAndPan = (movement: Movement, document: CollectionFreeFormView) => { - const { panX, panY, scale } = movement; - (scale !== 0 && preScale !== scale) && document.zoomSmoothlyAboutPt([panX, panY], scale, 0); - document.Document._panX = panX; - document.Document._panY = panY; - - preScale = scale; + const handleFirstMovements = () => { + // if the first movement is a closed tab, open it + const firstMovement = filteredMovements[0]; + const isClosed = this.getCollectionFFView(firstMovement.docId) === undefined; + if (isClosed) this.openTab(firstMovement.docId, docIdtoDoc); + + // for the open tabs, set it to the first move + const docIdtoFirstMove = this.getFirstMovements(filteredMovements, timeViewed); + for (const [docId, firstMove] of docIdtoFirstMove) { + const colFFView = this.getCollectionFFView(docId); + if (colFFView) this.zoomAndPan(firstMove, colFFView); + } } - // set the first frame to be at the start of the pres - // zoomAndPan(filteredMovements[0]); - - // generate a set of all unique docIds - const docIds = new Set(filteredMovements.map(movement => movement.docId)) - // TODO: optimize only ons-first load - // TODO: make async await - // TODO: make sure the cahce still hs the id - // TODO: if they are open, set them to their first move - // this will load the cache, so getCachedREfFields won't have to reach server - DocServer.GetRefFields([...docIds]).then(refFields => { - console.log('refFields', refFields) - - const openTab = (docId: string) : DocumentView | undefined => { - const isInView = DocumentManager.Instance.getDocumentViewById(docId); - if (isInView) { return isInView; } - - const doc = DocServer.GetCachedRefField(docId) as Doc; - if (doc == undefined) { - console.warn('Doc server cache did not contain docId', docId) - return undefined; + handleFirstMovements(); + + + // make timers that will execute each movement at the correct replay time + this.timers = filteredMovements.map(movement => { + const timeDiff = movement.time - timeViewed * 1000 + + return setTimeout(() => { + const collectionFFView = this.getCollectionFFView(movement.docId); + if (collectionFFView) { + this.zoomAndPan(movement, collectionFFView); + } else { + // tab wasn't open - open it and play the movement + const openedColFFView = this.openTab(movement.docId, docIdtoDoc); + openedColFFView && this.zoomAndPan(movement, openedColFFView); } - CollectionDockingView.AddSplit(doc, 'right'); - return DocumentManager.Instance.getDocumentView(doc); - } - // make timers that will execute each movement at the correct replay time - this.timers = filteredMovements.map(movement => { - const timeDiff = movement.time - timeViewed * 1000 - return setTimeout(() => { - // open tab if it is not already open - const view = openTab(movement.docId); - if (view) { - const collectionFFView = view.ComponentView; - console.log(collectionFFView instanceof CollectionFreeFormView) - // replay the movement - zoomAndPan(movement, collectionFFView as CollectionFreeFormView); - } - // if last movement, presentation is done -> set the instance var - if (movement === filteredMovements[filteredMovements.length - 1]) RecordingApi.Instance._isPlaying = false; - }, timeDiff) - }); - }) + // if last movement, presentation is done -> cleanup :) + if (movement === filteredMovements[filteredMovements.length - 1]) { + this.endPlayingPresentation(); + } + }, timeDiff); + }); } // method that concatenates an array of presentatations into one diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index ed9bcf29b..34df03954 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -28,7 +28,7 @@ import { AnchorMenu } from "../pdf/AnchorMenu"; import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; import "./VideoBox.scss"; -import { RecordingApi } from "../../util/RecordingApi"; +import { Presentation, RecordingApi } from "../../util/RecordingApi"; import { List } from "../../../fields/List"; import { RecordingBox } from "./RecordingBox"; const path = require('path'); @@ -48,80 +48,80 @@ const path = require('path'); @observer export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } - /** - * Uploads an image buffer to the server and stores with specified filename. by default the image - * is stored at multiple resolutions each retrieved by using the filename appended with _o, _s, _m, _l (indicating original, small, medium, or large) - * @param imageUri the bytes of the image - * @param returnedFilename the base filename to store the image on the server - * @param nosuffix optionally suppress creating multiple resolution images - */ - public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false, replaceRootFilename?: string) { - try { - const posting = Utils.prepend("/uploadURI"); - const returnedUri = await rp.post(posting, { - body: { - uri: imageUri, - name: returnedFilename, - nosuffix, - replaceRootFilename - }, - json: true, - }); - return returnedUri; - - } catch (e) { - console.log("VideoBox :" + e); - } - } - - static _youtubeIframeCounter: number = 0; - static heightPercent = 80; // height of video relative to videoBox when timeline is open - private _disposers: { [name: string]: IReactionDisposer } = {}; - private _youtubePlayer: YT.Player | undefined = undefined; - private _videoRef: HTMLVideoElement | null = null; // <video> ref - private _contentRef: HTMLDivElement | null = null; // ref to div that wraps video and controls for full screen - private _youtubeIframeId: number = -1; - private _youtubeContentCreated = false; - private _audioPlayer: HTMLAudioElement | null = null; - private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); // outermost div - private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); - private _playRegionTimer: any = null; // timeout for playback - @observable _stackedTimeline: any; // CollectionStackedTimeline ref - @observable static _nativeControls: boolean; // default html controls - @observable _marqueeing: number[] | undefined; // coords for marquee selection - @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); - @observable _screenCapture = false; - @observable _clicking = false; // used for transition between showing/hiding timeline - @observable _forceCreateYouTubeIFrame = false; - @observable _playTimer?: NodeJS.Timeout = undefined; - @observable _fullScreen = false; - @observable _playing = false; - @observable _finished: boolean = false; // has playback reached end of clip - @observable _volume: number = 1; - @observable _muted: boolean = false; - - @computed get links() { return DocListCast(this.dataDoc.links); } - @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } // current percent of video relative to VideoBox height - // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } - @observable rawDuration: number = 0; - - - @computed get youtubeVideoId() { - const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); - return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; - } - - - // returns the path of the audio file - @computed get audiopath() { - const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); - const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); - return field?.url.href ?? vfield?.url.href ?? ""; - } - - // returns the presentation data if it exists, null otherwise - @computed get presentation() { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } + /** + * Uploads an image buffer to the server and stores with specified filename. by default the image + * is stored at multiple resolutions each retrieved by using the filename appended with _o, _s, _m, _l (indicating original, small, medium, or large) + * @param imageUri the bytes of the image + * @param returnedFilename the base filename to store the image on the server + * @param nosuffix optionally suppress creating multiple resolution images + */ + public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false, replaceRootFilename?: string) { + try { + const posting = Utils.prepend("/uploadURI"); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename, + nosuffix, + replaceRootFilename + }, + json: true, + }); + return returnedUri; + + } catch (e) { + console.log("VideoBox :" + e); + } + } + + static _youtubeIframeCounter: number = 0; + static heightPercent = 80; // height of video relative to videoBox when timeline is open + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _youtubePlayer: YT.Player | undefined = undefined; + private _videoRef: HTMLVideoElement | null = null; // <video> ref + private _contentRef: HTMLDivElement | null = null; // ref to div that wraps video and controls for full screen + private _youtubeIframeId: number = -1; + private _youtubeContentCreated = false; + private _audioPlayer: HTMLAudioElement | null = null; + private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); // outermost div + private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + private _playRegionTimer: any = null; // timeout for playback + @observable _stackedTimeline: any; // CollectionStackedTimeline ref + @observable static _nativeControls: boolean; // default html controls + @observable _marqueeing: number[] | undefined; // coords for marquee selection + @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @observable _screenCapture = false; + @observable _clicking = false; // used for transition between showing/hiding timeline + @observable _forceCreateYouTubeIFrame = false; + @observable _playTimer?: NodeJS.Timeout = undefined; + @observable _fullScreen = false; + @observable _playing = false; + @observable _finished: boolean = false; // has playback reached end of clip + @observable _volume: number = 1; + @observable _muted: boolean = false; + + @computed get links() { return DocListCast(this.dataDoc.links); } + @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } // current percent of video relative to VideoBox height + // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @observable rawDuration: number = 0; + + + @computed get youtubeVideoId() { + const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); + return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; + } + + + // returns the path of the audio file + @computed get audiopath() { + const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); + const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); + return field?.url.href ?? vfield?.url.href ?? ""; + } + + // returns the presentation data if it exists, null otherwise + @computed get presentation(): Presentation | null { const data = this.dataDoc[this.fieldKey + '-presentation']; return data ? JSON.parse(data) : null; } |