import { IReactionDisposer, observable, observe, reaction } from "mobx"; import { NumCast } from "../../fields/Types"; import { Doc, DocListCast } from "../../fields/Doc"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; import { Id } from "../../fields/FieldSymbols"; export 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 TrackMovements { 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: TrackMovements; static get Instance(): TrackMovements { return TrackMovements._instance } constructor() { // init the global instance TrackMovements._instance = this; // init the instance variables this.currentPresentation = TrackMovements.NULL_PRESENTATION this.tracking = false; this.absoluteStart = -1; // used for tracking movements in the view frame this.recordingFFViews = null; this.tabChangeDisposeFunc = 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.trackMovement(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; } } } } private 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.info('change in tabs', change); this.updateRecordingFFViewsFromTabs(DocListCast(change), true); }); } 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 */ 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.info('yieldPresentation', cpy); return cpy; } finish = (): void => { // make is tracking false this.tracking = false // reset the RecordingApi instance this.clear(); } private 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 = TrackMovements.NULL_PRESENTATION // clear absoluteStart this.absoluteStart = -1 } removeAllRecordingFFViews = () => { if (this.recordingFFViews === null) { console.warn('removeAllFFViews on null RecordingApi'); return; } for (const [id, disposeFunc] of this.recordingFFViews) { // console.info('calling dispose func : docId', id); disposeFunc(); this.recordingFFViews.delete(id); } } private trackMovement = (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) } // 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 }; } }