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