aboutsummaryrefslogtreecommitdiff
path: root/src/client/util/ReplayMovements.ts
blob: 4f0423342b893b6f7b36dd67c6cdf6948af82c6d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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';
import { FieldViewProps } from '../views/nodes/FieldView';

export class ReplayMovements {
    private timers: NodeJS.Timeout[] | null;
    private videoBoxDisposeFunc: IReactionDisposer | null;
    private videoBox: ViewBoxInterface<FieldViewProps> | 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<FieldViewProps>) => {
        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') {
                const doc = IdToDoc(movement.doc);
                if (!doc) {
                    console.log('ERROR: tracked doc not found');
                } else {
                    movements[i].doc = doc;
                }
            }
        });
    };

    // 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<Doc, Movement> => {
        if (movements === null) return new Map();
        // generate a set of all unique docIds
        const docIdtoFirstMove = new Map<Doc, Movement>();
        movements.forEach(move => {
            if (!docIdtoFirstMove.has(move.doc as Doc)) docIdtoFirstMove.set(move.doc as 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 as Doc) === undefined;
            if (isClosed) this.openTab(firstMovement.doc as 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 as Doc);
                if (collectionFFView) {
                    this.zoomAndPan(movement, collectionFFView);
                } else {
                    // tab wasn't open - open it and play the movement
                    const openedColFFView = this.openTab(movement.doc as Doc);
                    openedColFFView && this.zoomAndPan(movement, openedColFFView);
                }

                // if last movement, presentation is done -> cleanup :)
                if (movement === filteredMovements[filteredMovements.length - 1]) {
                    this.endPlayingPresentation();
                }
            }, timeDiff);
        });
        return undefined;
    };
}