import { IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { Doc, IdToDoc } from '../../fields/Doc'; import { CollectionFreeFormView } from '../views/collections/collectionFreeForm'; import { DocumentView } from '../views/nodes/DocumentView'; import { OpenWhereMod } from '../views/nodes/OpenWhere'; import { SnappingManager } from './SnappingManager'; import { Movement, Presentation } from './TrackMovements'; import { ViewBoxInterface } from '../views/ViewBoxInterface'; import { StrCast } from '../../fields/Types'; export class ReplayMovements { private timers: NodeJS.Timeout[] | null; private videoBoxDisposeFunc: IReactionDisposer | null; private videoBox: ViewBoxInterface | null; private isPlaying: boolean; // create static instance and getter for global use // eslint-disable-next-line no-use-before-define @observable static _instance: ReplayMovements; static get Instance(): ReplayMovements { return ReplayMovements._instance; } constructor() { makeObservable(this); // init the global instance ReplayMovements._instance = this; // instance vars for replaying this.timers = null; this.videoBoxDisposeFunc = null; this.videoBox = null; this.isPlaying = false; reaction( () => SnappingManager.UserPanned, () => { if (Doc.UserDoc()?.presentationMode === 'watching') this.pauseFromInteraction(); } ); reaction( () => DocumentView.Selected().slice(), selviews => { const selVideo = selviews.find(dv => dv.ComponentView?.playFrom); if (selVideo?.ComponentView?.Play) { this.setVideoBox(selVideo.ComponentView); } else this.removeVideoBox(); } ); } // pausing movements will dispose all timers that are planned to replay the movements // play movemvents will recreate them when the user resumes the presentation pauseMovements = (): undefined | Error => { if (!this.isPlaying) { // console.warn('[recordingApi.ts] pauseMovements(): already on paused'); return; } Doc.UserDoc().presentationMode = 'none'; this.isPlaying = false; // TODO: set userdoc presentMode to browsing this.timers?.map(timer => clearTimeout(timer)); }; setVideoBox = async (videoBox: ViewBoxInterface) => { if (this.videoBox !== null) { console.warn('setVideoBox on already videoBox'); } this.videoBoxDisposeFunc?.(); const data = StrCast(videoBox.dataDoc?.[videoBox.fieldKey + '_presentation']); const presentation = data ? JSON.parse(data) : null; if (presentation === null) { console.warn('setVideoBox on null videoBox presentation'); return; } this.loadPresentation(presentation); this.videoBoxDisposeFunc = reaction( () => ({ playing: videoBox.IsPlaying?.(), timeViewed: videoBox.PlayerTime?.() || 0 }), ({ playing, timeViewed }) => (playing ? this.playMovements(presentation, timeViewed) : this.pauseMovements()) ); this.videoBox = videoBox; }; removeVideoBox = () => { this.videoBoxDisposeFunc?.(); this.videoBox = null; this.videoBoxDisposeFunc = null; }; // should be called from interacting with the screen pauseFromInteraction = () => { this.videoBox?.Pause?.(); this.pauseMovements(); }; loadPresentation = (presentation: Presentation) => { const { movements } = presentation; if (movements === null) { throw new Error('[recordingApi.ts] followMovements() failed: no presentation data'); } movements.forEach((movement, i) => { if (typeof movement.doc === 'string') { movements[i].doc = IdToDoc(movement.doc); if (!movements[i].doc) { console.log('ERROR: tracked doc not found'); } } }); }; // returns undefined if the docView isn't open on the screen getCollectionFFView = (doc: Doc) => { const isInView = DocumentView.getDocumentView(doc); return isInView?.ComponentView as CollectionFreeFormView; }; // will open the doc in a tab then return the CollectionFFView that holds it openTab = (doc: Doc) => { if (doc === undefined) { console.error(`doc undefined`); return undefined; } // console.log('openTab', docId, doc); DocumentView.addSplit(doc, OpenWhereMod.right); const docView = DocumentView.getDocumentView(doc); // BUG - this returns undefined if the doc is already open return docView?.ComponentView as CollectionFreeFormView; }; // helper to replay a movement zoomAndPan = (movement: Movement, document: CollectionFreeFormView) => { const { panX, panY, scale } = movement; scale !== 0 && document.zoomSmoothlyAboutPt([panX, panY], scale, 0); document.Document._freeform_panX = panX; document.Document._freeform_panY = panY; }; getFirstMovements = (movements: Movement[]): Map => { if (movements === null) return new Map(); // generate a set of all unique docIds const docIdtoFirstMove = new Map(); movements.forEach(move => { if (!docIdtoFirstMove.has(move.doc)) docIdtoFirstMove.set(move.doc, move); }); return docIdtoFirstMove; }; endPlayingPresentation = () => { this.isPlaying = false; Doc.UserDoc().presentationMode = 'none'; }; public playMovements = (presentation: Presentation, timeViewed: number = 0) => { // console.info('playMovements', presentation, timeViewed, docIdtoDoc); if (presentation.movements === null || presentation.movements.length === 0) { // || this.playFFView === null) { return '[recordingApi.ts] followMovements() failed: no presentation data'; } if (this.isPlaying) return undefined; 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.doc) === undefined; if (isClosed) this.openTab(firstMovement.doc); // for the open tabs, set it to the first move const docIdtoFirstMove = this.getFirstMovements(filteredMovements); Array.from(docIdtoFirstMove).forEach(([doc, firstMove]) => { const colFFView = this.getCollectionFFView(doc); 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.doc); if (collectionFFView) { this.zoomAndPan(movement, collectionFFView); } else { // tab wasn't open - open it and play the movement const openedColFFView = this.openTab(movement.doc); openedColFFView && this.zoomAndPan(movement, openedColFFView); } // if last movement, presentation is done -> cleanup :) if (movement === filteredMovements[filteredMovements.length - 1]) { this.endPlayingPresentation(); } }, timeDiff); }); return undefined; }; }