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  AfterViewInit,
19  Component,
20  ElementRef,
21  Input,
22  NgZone,
23  OnDestroy,
24  ViewChild,
25} from '@angular/core';
26import { VideoSource } from '../video-source';
27import { Disposer } from '../../utils/disposer';
28import { checkNotNull } from '../../utils/preconditions';
29
30@Component({
31  selector: 'app-video-view',
32  standalone: true,
33  templateUrl: './video-view.component.html',
34  styleUrls: ['./video-view.component.scss'],
35})
36export class VideoViewComponent implements AfterViewInit, OnDestroy {
37  private _source?: VideoSource;
38  private _sourceDisposer = new Disposer(true);
39
40  private _observerDisposer = new Disposer(true);
41  private _observer = new ResizeObserver(() => {
42    this.zone.run(() => {
43      this.updateVideoSize();
44    });
45  });
46
47  constructor(private zone: NgZone) {}
48
49  private _container?: ElementRef<HTMLDivElement>;
50
51  @ViewChild('container', { read: ElementRef })
52  set container(value: ElementRef<HTMLDivElement> | undefined) {
53    this._observerDisposer.dispose();
54
55    this._container = value;
56
57    if (this._container) {
58      const containerElement = this._container.nativeElement;
59      this._observer.observe(containerElement);
60      this._observerDisposer.addFunction(() => {
61        this._observer.unobserve(containerElement);
62      });
63      this.updateVideoSize();
64    }
65  }
66
67  @ViewChild('canvas', { read: ElementRef })
68  canvas?: ElementRef<HTMLCanvasElement>;
69
70  @Input()
71  set source(newSource: VideoSource | undefined) {
72    if (this._source === newSource) return;
73
74    this._sourceDisposer.dispose();
75    this._source = newSource;
76    this.updateVideoSize();
77
78    if (newSource) {
79      newSource.play();
80      this._sourceDisposer.addListener(newSource, `metadata-changed`, () => {
81        this.updateVideoSize();
82      });
83      requestAnimationFrame(() => this._onFrameAvailable());
84    }
85  }
86
87  get source() {
88    return this._source;
89  }
90
91  ngAfterViewInit(): void {
92    this.updateVideoSize();
93  }
94
95  ngOnDestroy() {
96    this._observerDisposer.dispose();
97  }
98
99  updateVideoSize() {
100    if (!this.canvas) return;
101    const canvasElement = this.canvas.nativeElement;
102    if (!this._container || !this._source) {
103      canvasElement.width = canvasElement.height = 0;
104      return;
105    }
106
107    // Determine the scaling ratio for both, witdth and height, to fit the video into the container.
108    const widthScale =
109      this._container.nativeElement.clientWidth / this._source.width;
110    const heightScale =
111      this._container.nativeElement.clientHeight / this._source.height;
112    // Pick the smaller scale factor to ensure both width and height will fit, however do not allow
113    // scaling up.
114    const scale = Math.min(widthScale, heightScale, 1);
115
116    canvasElement.width = this._source.width * scale;
117    canvasElement.height = this._source.height * scale;
118
119    this._source.drawCurrentFrame(checkNotNull(canvasElement.getContext('2d')));
120  }
121
122  _onFrameAvailable() {
123    if (!this._source) return;
124
125    const canvasElement = this.canvas?.nativeElement?.getContext('2d');
126    this._source.drawCurrentFrame(checkNotNull(canvasElement));
127    requestAnimationFrame(() => this._onFrameAvailable());
128  }
129}
130