import { CollectionFreeFormView } from "../views/collections/collectionFreeForm"; import { IReactionDisposer, observable, observe, reaction } from "mobx"; import { NumCast } from "../../fields/Types"; import { Doc, DocListCast } from "../../fields/Doc"; import { VideoBox } from "../views/nodes/VideoBox"; import { isArray } from "lodash"; import { SelectionManager } from "./SelectionManager"; import { DocumentDecorations } from "../views/DocumentDecorations"; import { DocumentManager } from "./DocumentManager"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; import { Id } from "../../fields/FieldSymbols"; import { returnAll } from "../../Utils"; import { ContextExclusionPlugin } from "webpack"; import { DocServer } from "../DocServer"; import { DocumentView } from "../views/nodes/DocumentView"; type Movement = { time: number, panX: number, panY: number, scale: number, docId: string, } type tabReactionFunction = (res : { x: number; y: number; scale: number; key: string; }) => void; export type Presentation = { movements: Movement[] | null, totalTime: number, meta: Object | Object[], } export class RecordingApi { private static get NULL_PRESENTATION(): Presentation { return { movements: null, meta: {}, totalTime: -1, } } // instance variables private currentPresentation: Presentation; private tracking: boolean; private absoluteStart: number; // instance variable for holding the FFViews and their disposers private recordingFFViews: Map | null; private tabChangeDisposeFunc: IReactionDisposer | null; // create static instance and getter for global use @observable static _instance: RecordingApi; public static get Instance(): RecordingApi { return RecordingApi._instance } public constructor() { // init the global instance RecordingApi._instance = this; // init the instance variables this.currentPresentation = RecordingApi.NULL_PRESENTATION this.tracking = false; this.absoluteStart = -1; // used for tracking movements in the view frame this.recordingFFViews = new Map(); this.tabChangeDisposeFunc = null; // for now, set playFFView // this.playFFView = null; this.timers = null; } // little helper :) private get nullPresentation(): boolean { return this.currentPresentation.movements === null } private addRecordingFFView(doc: Doc, key: string = doc[Id]): void { console.info('adding dispose func : docId', key, 'doc', doc); if (this.recordingFFViews === null) { console.warn('addFFView on null RecordingApi'); return; } if (this.recordingFFViews.has(key)) { console.warn('addFFView : key already in map'); return; } const disposeFunc = reaction( () => ({ x: NumCast(doc.panX, -1), y: NumCast(doc.panY, -1), scale: NumCast(doc.viewScale, 0)}), (res) => (res.x !== -1 && res.y !== -1 && this.tracking) && this.trackMovements(res.x, res.y, key, res.scale), ); this.recordingFFViews?.set(key, disposeFunc); } private addRecordingFFViewTest(doc: Doc, reactionFunc: any): void { console.info('adding dispose funcTest : key', doc[Id], 'reactionFunc', reactionFunc); const key = doc[Id]; if (this.recordingFFViews === null) { console.warn('addFFViewTest on null RecordingApi'); return; } console.log('addFFViewTest : key', key, 'map', this.recordingFFViews); if (this.recordingFFViews.has(key)) { console.warn('addFFViewTest : key already in map'); return; } const disposeFunc = reaction( () => ({ x: NumCast(doc.panX, -1), y: NumCast(doc.panY, -1), scale: NumCast(doc.viewScale, 0), key: doc[Id] }), (res) => reactionFunc(res), ); this.recordingFFViews?.set(key, disposeFunc); } private removeRecordingFFView = (key: string) => { console.info('removing dispose func : docId', key); if (this.recordingFFViews === null) { console.warn('removeFFView on null RecordingApi'); return; } this.recordingFFViews.get(key)?.(); this.recordingFFViews.delete(key); } // in the case where only one tab was changed (updates not across dashboards), set only one to true private updateRecordingFFViewsFromTabsTest = (tabbedDocs: Doc[], reactionFunc: any, onlyOne = false) => { if (this.recordingFFViews === null) return; // so that the size comparisons are correct, we must filter to only the FFViews const isFFView = (doc: Doc) => doc && 'viewType' in doc && doc.viewType === 'freeform'; const tabbedFFViews = new Set(); for (const DashDoc of tabbedDocs) { if (isFFView(DashDoc)) tabbedFFViews.add(DashDoc[Id]); } // new tab was added - need to add it if (tabbedFFViews.size > this.recordingFFViews.size) { for (const DashDoc of tabbedDocs) { if (!this.recordingFFViews.has(DashDoc[Id])) { if (isFFView(DashDoc)) { this.addRecordingFFViewTest(DashDoc, reactionFunc); // only one max change, so return if (onlyOne) return; } } } } // tab was removed - need to remove it from recordingFFViews else if (tabbedFFViews.size < this.recordingFFViews.size) { for (const [key] of this.recordingFFViews) { if (!tabbedFFViews.has(key)) { this.removeRecordingFFView(key); if (onlyOne) return; } } } } // in the case where only one tab was changed (updates not across dashboards), set only one to true private updateRecordingFFViewsFromTabs = (tabbedDocs: Doc[], onlyOne = false) => { if (this.recordingFFViews === null) return; // so that the size comparisons are correct, we must filter to only the FFViews const isFFView = (doc: Doc) => doc && 'viewType' in doc && doc.viewType === 'freeform'; const tabbedFFViews = new Set(); for (const DashDoc of tabbedDocs) { if (isFFView(DashDoc)) tabbedFFViews.add(DashDoc[Id]); } // new tab was added - need to add it if (tabbedFFViews.size > this.recordingFFViews.size) { for (const DashDoc of tabbedDocs) { if (!this.recordingFFViews.has(DashDoc[Id])) { if (isFFView(DashDoc)) { this.addRecordingFFView(DashDoc); // only one max change, so return if (onlyOne) return; } } } } // tab was removed - need to remove it from recordingFFViews else if (tabbedFFViews.size < this.recordingFFViews.size) { for (const [key] of this.recordingFFViews) { if (!tabbedFFViews.has(key)) { this.removeRecordingFFView(key); if (onlyOne) return; } } } } private initTabWatcher = (reactionFunc: tabReactionFunction) => { // init the dispose funcs on the page const docList = DocListCast(CollectionDockingView.Instance.props.Document.data); this.updateRecordingFFViewsFromTabsTest(docList, reactionFunc); // create a reaction to monitor changes in tabs this.tabChangeDisposeFunc = reaction(() => CollectionDockingView.Instance.props.Document.data, (change) => { // TODO: consider changing between dashboards console.log('change in tabs', change); this.updateRecordingFFViewsFromTabsTest(DocListCast(change), reactionFunc, true); }); } public start = (meta?: Object) => { const reactionFunc: tabReactionFunction = (res) => (res.x !== -1 && res.y !== -1 && this.tracking) && this.trackMovements(res.x, res.y, res.key, res.scale) this.initTabWatcher(reactionFunc); // update the presentation mode Doc.UserDoc().presentationMode = 'recording'; // (1a) get start date for presenation const startDate = new Date(); // (1b) set start timestamp to absolute timestamp this.absoluteStart = startDate.getTime(); // (2) assign meta content if it exists this.currentPresentation.meta = meta || {} // (3) assign start date to currentPresenation this.currentPresentation.movements = [] // (4) set tracking true to allow trackMovements this.tracking = true } /* stops the video and returns the presentatation; if no presentation, returns undefined */ public yieldPresentation(clearData: boolean = true): Presentation | null { // if no presentation or done tracking, return null if (this.nullPresentation || !this.tracking) return null; // set the previus recording view to the play view // this.playFFView = this.recordingFFView; // ensure we add the endTime now that they are done recording const cpy = { ...this.currentPresentation, totalTime: new Date().getTime() - this.absoluteStart }; // reset the current presentation clearData && this.clear(); console.log('yieldPresentation', cpy, 'map', this.recordingFFViews); return cpy; } public finish = (): void => { // make is tracking false this.tracking = false // reset the RecordingApi instance this.clear(); } public clear = (): void => { // clear the disposeFunc if we are done (not tracking) if (!this.tracking) { this.removeAllRecordingFFViews(); this.tabChangeDisposeFunc?.(); // update the presentation mode now that we are done tracking Doc.UserDoc().presentationMode = 'none'; } // clear presenation data this.currentPresentation = RecordingApi.NULL_PRESENTATION // clear absoluteStart this.absoluteStart = -1 } private removeAllRecordingFFViews = () => { if (this.recordingFFViews === null) { console.warn('removeAllFFViews on null RecordingApi'); return; } for (const [id, disposeFunc] of this.recordingFFViews) { console.log('calling dispose func : docId', id); disposeFunc(); this.recordingFFViews.delete(id); } } private trackMovements = (panX: number, panY: number, docId: string, scale: number = 0) => { // ensure we are recording to track if (!this.tracking) { console.error('[recordingApi.ts] trackMovements(): tracking is false') return; } // check to see if the presetation is init - if not, we are between segments // TODO: make this more clear - tracking should be "live tracking", not always true when the recording api being used (between start and yieldPres) // bacuse tracking should be false inbetween segments high key if (this.nullPresentation) { console.warn('[recordingApi.ts] trackMovements(): trying to store movemetns between segments') return; } // get the time const time = new Date().getTime() - this.absoluteStart // make new movement object const movement: Movement = { time, panX, panY, scale, docId } // add that movement to the current presentation data's movement array this.currentPresentation.movements && this.currentPresentation.movements.push(movement) } // TODO: extract this into different class with pause and resume recording // TODO: store the FFview with the movements private timers: NodeJS.Timeout[] | null; // pausing movements will dispose all timers that are planned to replay the movements // play movemvents will recreate them when the user resumes the presentation public pauseMovements = (): undefined | Error => { if (!this._isPlaying) { //return new Error('[recordingApi.ts] pauseMovements() failed: not playing') return } this._isPlaying = false // TODO: set userdoc presentMode to browsing this.timers?.map(timer => clearTimeout(timer)) // this.videoBox = null; } private videoBoxDisposeFunc: IReactionDisposer | null = null; setVideoBox = (videoBox: VideoBox) => { console.log('setVideoBox', videoBox); if (this.videoBoxDisposeFunc !== null) { console.warn('setVideoBox on already videoBox'); this.videoBoxDisposeFunc(); } this.videoBoxDisposeFunc = reaction(() => ({ playing: videoBox._playing, timeViewed: videoBox.player?.currentTime || 0 }), ({ playing, timeViewed }) => playing ? this.playMovements(videoBox.presentation, timeViewed) : this.pauseMovements() ); } removeVideoBox = () => { if (this.videoBoxDisposeFunc == null) { console.warn('removeVideoBox on null videoBox'); return; } this.videoBoxDisposeFunc(); } // by calling pause on the VideoBox, the pauseMovements will be called public pauseVideoAndMovements = () => { // this.videoBox?.Pause() this.pauseMovements() // return this.videoBox == null } public _isPlaying = false; public playMovements = (presentation: Presentation, timeViewed: number = 0, videoBox?: VideoBox): undefined | Error => { console.log('playMovements', presentation, timeViewed, videoBox); if (presentation.movements === null) { //|| this.playFFView === null) { return new Error('[recordingApi.ts] followMovements() failed: no presentation data') } if (this._isPlaying) return; this._isPlaying = true; Doc.UserDoc().presentationMode = 'watching'; // setup the reaction on tabs that will pause the video if the user interacts with a tab // 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; } // 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; } 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) }); }) } // method that concatenates an array of presentatations into one public concatPresentations = (presentations: Presentation[]): Presentation => { // these three will lead to the combined presentation let combinedMovements: Movement[] = []; let sumTime = 0; let combinedMetas: any[] = []; presentations.forEach((presentation) => { const { movements, totalTime, meta } = presentation; // update movements if they had one if (movements) { // add the summed time to the movements const addedTimeMovements = movements.map(move => { return { ...move, time: move.time + sumTime } }); // concat the movements already in the combined presentation with these new ones combinedMovements.push(...addedTimeMovements); } // update the totalTime sumTime += totalTime; // concatenate the metas combinedMetas.push(meta); }); // return the combined presentation with the updated total summed time return { movements: combinedMovements, totalTime: sumTime, meta: combinedMetas }; } }