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 { Disposable, Disposer } from '../util/disposer'; 18import { checkNotNull } from '../util/preconditions'; 19import { Deferred } from '../util/util'; 20import { Timeline } from './timeline'; 21 22export type VideoSourceState = 'stop' | 'play' | 'seek'; 23 24export class VideoSource 25 extends EventTarget 26 implements VideoSource, Disposable 27{ 28 readonly seekable = true; 29 private _videoElement: HTMLVideoElement | null = null; 30 31 constructor(recordingUrl: string, readonly timeline: Timeline) { 32 super(); 33 const videoElement = document.createElement('video'); 34 videoElement.muted = true; 35 videoElement.src = recordingUrl; 36 videoElement.addEventListener('timeupdate', (e) => { 37 this.dispatchEvent(new Event('timeupdate')); 38 }), 39 videoElement.addEventListener('resize', (e) => 40 this.dispatchEvent(new Event('metadata-changed')) 41 ); 42 43 this._videoElement = videoElement; 44 } 45 46 drawCurrentFrame(ctx: CanvasRenderingContext2D): void { 47 if (!this._videoElement) return; 48 ctx.drawImage( 49 this._videoElement, 50 0, 51 0, 52 ctx.canvas.width, 53 ctx.canvas.height 54 ); 55 } 56 57 get width(): number { 58 return this._videoElement?.videoWidth ?? 0; 59 } 60 61 get height(): number { 62 return this._videoElement?.videoHeight ?? 0; 63 } 64 65 get loop(): boolean { 66 return this._videoElement?.loop ?? false; 67 } 68 69 set loop(value: boolean) { 70 checkNotNull(this._videoElement).loop = value; 71 } 72 73 get playbackRate(): number { 74 return this._videoElement?.playbackRate ?? 1; 75 } 76 77 set playbackRate(value: number) { 78 checkNotNull(this._videoElement).playbackRate = value; 79 } 80 81 get state() { 82 if (!this._videoElement) return 'stop'; 83 if (this._currentSeekPromise) return 'seek'; 84 if (this._videoElement.paused) return 'stop'; 85 if (this._videoElement.ended) return 'stop'; 86 87 return 'play'; 88 } 89 90 async play(): Promise<void> { 91 this._videoElement?.play(); 92 } 93 94 async stop(): Promise<void> { 95 this._videoElement?.pause(); 96 this._cancelSeek(); 97 } 98 99 dispose(): void { 100 this._cancelSeek(); 101 if (this._videoElement) { 102 this._videoElement.pause(); 103 URL.revokeObjectURL(this._videoElement.src); 104 this._videoElement.src = ''; 105 } 106 } 107 108 get currentTime() { 109 return this._videoElement?.currentTime ?? 0; 110 } 111 112 _currentSeekPromise: Deferred<boolean> | null = null; 113 114 async seek(time: number): Promise<boolean> { 115 if (!this._videoElement) return false; 116 117 this._cancelSeek(); 118 119 if (this._videoElement.currentTime == time) return true; 120 121 const currentSeekPromise = new Deferred<boolean>(); 122 this._currentSeekPromise = currentSeekPromise; 123 124 const seekSetup = new Disposer(); 125 seekSetup.addListener(this._videoElement, 'seeked', () => { 126 currentSeekPromise.resolve(true); 127 }); 128 129 this._videoElement.currentTime = time; 130 131 try { 132 return await currentSeekPromise; 133 } finally { 134 seekSetup.dispose(); 135 if (this._currentSeekPromise == currentSeekPromise) { 136 this._currentSeekPromise = null; 137 } 138 } 139 } 140 141 private _cancelSeek() { 142 this._currentSeekPromise?.resolve(false); 143 this._currentSeekPromise = null; 144 } 145} 146