1/*
2 * Copyright 2024 Google LLC
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18  AfterViewChecked,
19  AfterViewInit,
20  Component,
21  ElementRef,
22  Input,
23  NgZone,
24  OnChanges,
25  OnDestroy,
26  QueryList,
27  SimpleChanges,
28  ViewChild,
29  ViewChildren,
30  ViewContainerRef,
31} from '@angular/core';
32import { checkNotNull } from '../../utils/preconditions';
33import {
34  CdkDrag,
35  CdkDragRelease,
36  CdkDragStart,
37  DragRef,
38  Point,
39} from '@angular/cdk/drag-drop';
40import { GraphComponent } from './graph/graph.component';
41import { VisualTimeline } from '../visual-timeline';
42import { Disposer } from '../../utils/disposer';
43import { RecordedMotion } from '../recorded-motion';
44import { VideoSource } from '../video-source';
45import { VideoControlsComponent } from '../video-controls/video-controls.component';
46import { Feature } from '../feature';
47
48@Component({
49  selector: 'app-timeline-view',
50  standalone: true,
51  imports: [GraphComponent, VideoControlsComponent, CdkDrag],
52
53  templateUrl: './timeline-view.component.html',
54  styleUrls: ['./timeline-view.component.scss'],
55})
56export class TimelineViewComponent
57  implements AfterViewInit, AfterViewChecked, OnDestroy, OnChanges
58{
59  private _recordingInputDisposer = new Disposer(true);
60
61  constructor(
62    private readonly viewRef: ViewContainerRef,
63    private zone: NgZone,
64  ) {}
65
66  ngOnDestroy(): void {
67    this._recordingInputDisposer.dispose();
68  }
69
70  private _observer = new ResizeObserver(() => {
71    this.zone.run(() => {
72      this._updateCanvasSize();
73    });
74  });
75
76  @ViewChildren(GraphComponent) graphs!: QueryList<GraphComponent>;
77
78  @ViewChild('canvas')
79  canvas!: ElementRef<HTMLCanvasElement>;
80
81  @Input()
82  recordedMotion: RecordedMotion | undefined;
83
84  ngOnChanges(changes: SimpleChanges): void {
85    if (changes['recordedMotion']) {
86      this.onRecordedMotionChanged();
87    }
88  }
89
90  get videoSource(): VideoSource | undefined {
91    return this.recordedMotion?.videoSource;
92  }
93  features: Feature[] = [];
94
95  onRecordedMotionChanged() {
96    this._recordingInputDisposer.dispose();
97
98    if (this.recordedMotion) {
99      this.visualTimeline = new VisualTimeline(
100        this.canvas?.nativeElement?.width ?? 1,
101        this.recordedMotion.timeline,
102      );
103
104      function recursiveFeatures(feature: Feature): Feature[] {
105        return [feature, ...feature.subFeatures.flatMap(it => recursiveFeatures(it))];
106      }
107
108      this.features = this.recordedMotion.features.flatMap((it) =>
109        recursiveFeatures(it),
110      );
111
112
113      this._recordingInputDisposer.addListener(
114        this.recordedMotion.videoSource,
115        'timeupdate',
116        () => this._updatePlayHead(),
117      );
118    } else {
119      this.visualTimeline = undefined;
120      this.features = [];
121    }
122
123    this._scheduleRender();
124  }
125
126  visualTimeline?: VisualTimeline;
127
128  ngAfterViewInit() {
129    this._observer.observe(this.viewRef.element.nativeElement);
130    this.graphs.changes.subscribe((r) => {
131      const width = this.canvas.nativeElement.width;
132      this.graphs.forEach((graph) => graph.updateCanvasSize(width));
133    });
134    this._updateCanvasSize();
135  }
136
137  timeHandlePosition = { x: 0, y: 0 };
138
139  _isPlaying = false;
140
141  ngAfterViewChecked() {
142    const isPlaying = this.videoSource?.state === 'play';
143    if (isPlaying == this._isPlaying) return;
144
145    this._isPlaying = isPlaying;
146    if (isPlaying) {
147      const self = this;
148      function continuouslyUpdatePlayhead() {
149        self._updatePlayHead();
150        if (self._isPlaying) requestAnimationFrame(continuouslyUpdatePlayhead);
151      }
152      requestAnimationFrame(continuouslyUpdatePlayhead);
153    }
154  }
155
156  private _updatePlayHead() {
157    if (!this.visualTimeline || !this.videoSource) return;
158    const playheadX = this.visualTimeline.timeToPxClamped(
159      this.videoSource.currentTime,
160    );
161    if (isFinite(playheadX)) {
162      this.timeHandlePosition = { x: playheadX, y: 0 };
163    }
164  }
165
166  private _wasPlayingBeforeDrag = false;
167  onDragTimeHandleStart(event: CdkDragStart) {
168    this._wasPlayingBeforeDrag = this.videoSource?.state == 'play';
169    if (this._wasPlayingBeforeDrag) {
170      this.videoSource?.stop();
171    }
172  }
173
174  computeTimeHandleSnap = (
175    pos: Point,
176    dragRef: DragRef,
177    dimensions: ClientRect,
178    pickupPositionInElement: Point,
179  ) => {
180    if (!this.visualTimeline) return { x: 0, y: 0 };
181
182    const canvasBounds = this.canvas.nativeElement.getBoundingClientRect();
183
184    let frame = this.visualTimeline.pxToFrame(pos.x - canvasBounds.x);
185
186    if (frame === Number.NEGATIVE_INFINITY) frame = 0;
187    else if (frame === Number.POSITIVE_INFINITY)
188      frame = this.visualTimeline.timeline.frameCount;
189
190    if (this.videoSource) {
191      this.videoSource.seek(this.visualTimeline.timeline.frameToTime(frame));
192    }
193
194    return {
195      x: canvasBounds.x + this.visualTimeline.frameToPx(frame) - 5,
196      y: dimensions.y,
197    };
198  };
199
200  onDragTimeHandleEnd(event: CdkDragRelease) {
201    if (this._wasPlayingBeforeDrag) {
202      this.videoSource?.play();
203    }
204  }
205
206  private _updateCanvasSize() {
207    const canvasElement = this.canvas.nativeElement;
208    const parentElement = checkNotNull(this.canvas.nativeElement.parentElement);
209    const height = parentElement.clientHeight;
210    const width = parentElement.clientWidth;
211    if (canvasElement.width == width && canvasElement.height == height) {
212      return;
213    }
214
215    canvasElement.width = width;
216    canvasElement.height = height;
217    if (this.visualTimeline) {
218      this.visualTimeline.width = width;
219    }
220    this._render();
221    this._updatePlayHead();
222
223    this.graphs.forEach((graph) => graph.updateCanvasSize(width));
224  }
225
226  private _scheduledRender?: number;
227  private _scheduleRender() {
228    if (this._scheduledRender) return;
229
230    this._scheduledRender = requestAnimationFrame(() => {
231      this._render();
232      this._scheduledRender = undefined;
233    });
234  }
235
236  private _render() {
237    const ctx = checkNotNull(this.canvas.nativeElement.getContext('2d'));
238    const { width, height } = ctx.canvas;
239
240    const minMinorGap = 10;
241    const minMajorGap = 50;
242
243    ctx.clearRect(0, 0, width, height);
244    if (!this.recordedMotion) return;
245
246    const timeline = this.recordedMotion.timeline;
247    const framesCount = timeline.frameCount;
248
249    const maxMinorTicks = Math.min(
250      Math.floor(width / minMinorGap),
251      framesCount,
252    );
253
254    const minorGap = width / maxMinorTicks;
255
256    ctx.beginPath();
257    for (let x = 0.5 + minorGap; x <= width; x += minorGap) {
258      // Adding the gap skips the initial line at 0
259      const xr = Math.round(x);
260      let nx;
261
262      if (xr >= x) {
263        nx = xr - 0.5;
264      } else {
265        nx = xr + 0.5;
266      }
267      ctx.moveTo(nx, 0);
268      ctx.lineTo(nx, height - 20);
269    }
270
271    ctx.strokeStyle = '#EEEEEE';
272    ctx.lineWidth = 1;
273    ctx.stroke();
274
275    const majorGap = Math.max(2, Math.floor(minMajorGap / minorGap)) * minorGap;
276
277    ctx.strokeStyle = '#DDDDDD';
278    ctx.fillStyle = '#222222';
279    ctx.lineWidth = 2;
280
281    ctx.beginPath();
282    for (let x = majorGap; x < width; x += majorGap) {
283      // Adding the gap skips the initial line at 0
284      const xr = Math.round(x);
285
286      ctx.moveTo(xr, 0);
287      ctx.lineTo(xr, height - 15);
288
289      const frameNo = Math.floor((x / width) * framesCount);
290      const frameLabel = timeline.frameLabels[frameNo];
291
292      ctx.textAlign = 'center';
293      ctx.fillText(frameLabel, xr, height - 5);
294    }
295
296    // Always draw start
297    ctx.moveTo(1, 0);
298    ctx.lineTo(1, height - 15);
299
300    // Always draw end
301    ctx.moveTo(width - 1, 0);
302    ctx.lineTo(width - 1, height - 15);
303
304    ctx.stroke();
305  }
306}
307