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, } 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 = null; 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 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 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; } } } } 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); // 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.updateRecordingFFViewsFromTabs(DocListCast(change), true); }); } public start = (meta?: Object) => { this.initTabTracker(); // 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); 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'; this.recordingFFViews = null; this.tabChangeDisposeFunc = null; } // 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) { console.warn('[recordingApi.ts] pauseMovements(): already on paused'); return;} this._isPlaying = false // TODO: set userdoc presentMode to browsing this.timers?.map(timer => clearTimeout(timer)) // this.videoBox = null; } private videoBoxDisposeFunc: IReactionDisposer | null = null; private videoBox: VideoBox | null = null; setVideoBox = async (videoBox: VideoBox) => { console.log('setVideoBox', videoBox); 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 = 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(presentation, docIdtoDoc, timeViewed) : this.pauseMovements() ); this.videoBox = videoBox; } removeVideoBox = () => { if (this.videoBoxDisposeFunc == null) { console.warn('removeVideoBox on null videoBox'); return; } this.videoBoxDisposeFunc(); this.videoBox = null; this.videoBoxDisposeFunc = null; } // by calling pause on the VideoBox, the pauseMovements will be called public pauseFromInteraction = () => { Doc.UserDoc().presentationMode = 'none'; this.videoBox?.Pause(); this.pauseMovements(); // return this.videoBox == null } public _isPlaying = false; 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(); for (const {docId} of movements) { if (!docIds.has(docId)) docIds.add(docId); } const docIdtoDoc = new Map(); 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) => { 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 => { 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, timeViewed: number = 0) => { console.log('playMovements', presentation, timeViewed, docIdtoDoc); 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; this._isPlaying = true; Doc.UserDoc().presentationMode = 'watching'; // only get the movements that are remaining in the video time left const filteredMovements = presentation.movements.filter(movement => movement.time > timeViewed * 1000) 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); } } 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); } // 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 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 }; } }