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'; import { CollectionViewType } from '../documents/DocumentTypes'; export type Movement = { time: number; panX: number; panY: number; scale: number; doc: Doc; }; 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): 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(doc)) { console.warn('addFFView : doc already in map'); return; } const disposeFunc = reaction( () => ({ x: NumCast(doc.freeform_panX, -1), y: NumCast(doc.freeform_panY, -1), scale: NumCast(doc.freeform_scale, 0) }), res => res.x !== -1 && res.y !== -1 && this.tracking && this.trackMovement(res.x, res.y, doc, res.scale) ); this.recordingFFViews?.set(doc, disposeFunc); } private removeRecordingFFView = (doc: Doc) => { // console.info('removing dispose func : docId', key); if (this.recordingFFViews === null) { console.warn('removeFFView on null RecordingApi'); return; } this.recordingFFViews.get(doc)?.(); this.recordingFFViews.delete(doc); }; // 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 && doc._type_collection === CollectionViewType.Freeform; const tabbedFFViews = new Set(); for (const DashDoc of tabbedDocs) { if (isFFView(DashDoc)) tabbedFFViews.add(DashDoc); } // new tab was added - need to add it if (tabbedFFViews.size > this.recordingFFViews.size) { for (const DashDoc of tabbedDocs) { if (!this.recordingFFViews.has(DashDoc)) { 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 [doc] of this.recordingFFViews) { if (!tabbedFFViews.has(doc)) { this.removeRecordingFFView(doc); 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, doc: Doc, 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, doc }; // add that movement to the current presentation data's movement array 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 }; }; }