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 { Component, Input, OnDestroy } from '@angular/core'; 18import { MatButtonModule } from '@angular/material/button'; 19import { MatIconModule } from '@angular/material/icon'; 20import { MatMenuModule } from '@angular/material/menu'; 21import { Disposer } from '../../utils/disposer'; 22import { checkNotNull } from '../../utils/preconditions'; 23import { Preferences } from '../../utils/preferences'; 24import { VideoSource } from '../video-source'; 25import { 26 KeyboardShortcutsModule, 27 ShortcutEventOutput, 28 ShortcutInput, 29} from 'ng-keyboard-shortcuts'; 30 31@Component({ 32 selector: 'app-video-controls', 33 standalone: true, 34 imports: [ 35 MatIconModule, 36 MatButtonModule, 37 MatMenuModule, 38 KeyboardShortcutsModule, 39 ], 40 41 templateUrl: './video-controls.component.html', 42 styleUrls: ['./video-controls.component.scss'], 43}) 44export class VideoControlsComponent implements OnDestroy { 45 private _disposer = new Disposer(); 46 47 private _source?: VideoSource; 48 private _sourceDisposer = new Disposer(true); 49 50 constructor(private _preferences: Preferences) {} 51 52 @Input() 53 set videoSource(newSource: VideoSource | undefined) { 54 if (this._source === newSource) return; 55 56 this._sourceDisposer.dispose(); 57 this._source = newSource; 58 if (this._source) { 59 this._source.loop = this._preferences.loopVideo; 60 this._source.playbackRate = this._preferences.playbackRate; 61 } 62 } 63 64 ngOnDestroy(): void { 65 this._disposer.dispose(); 66 } 67 68 get playStateIcon() { 69 return this._source?.state == 'play' ? 'pause' : 'play_arrow'; 70 } 71 72 moveFrame(framesOffset: number) { 73 const videoSource = checkNotNull(this._source); 74 const timeline = videoSource.timeline; 75 videoSource.stop(); 76 77 const currentTime = videoSource.currentTime; 78 const thisFrame = timeline.timeToFrame(currentTime); 79 80 const nextFrame = Math.min( 81 Math.max(thisFrame + framesOffset, 0), 82 timeline.frameCount - 1, 83 ); 84 85 const targetTime = timeline.frameToTime(nextFrame) + 0.008; 86 87 return videoSource.seek(targetTime); 88 } 89 90 togglePlay() { 91 const videoSource = checkNotNull(this._source); 92 if (videoSource.state == 'play') { 93 videoSource.stop(); 94 } else { 95 videoSource.play(); 96 } 97 } 98 99 get repeatStateIcon() { 100 return this._preferences.loopVideo ? 'repeat_on' : 'repeat'; 101 } 102 103 toggleRepeat() { 104 const loopVideo = !this._preferences.loopVideo; 105 this._preferences.loopVideo = loopVideo; 106 checkNotNull(this._source).loop = loopVideo; 107 } 108 109 playbackRateOptions: number[] = [1, 0.5, 0.25, 0.125]; 110 111 get playbackRate() { 112 return this._preferences.playbackRate; 113 } 114 115 formatPlaybackRateLabel(rate: number): string { 116 return `${rate}x`; 117 } 118 119 setPlaybackRate(rate: number) { 120 this._preferences.playbackRate = rate; 121 checkNotNull(this._source).playbackRate = rate; 122 } 123 shortcuts: ShortcutInput[] = [ 124 { 125 key: ['left'], 126 label: 'Previous frame', 127 command: (output: ShortcutEventOutput) => this.moveFrame(-1), 128 }, 129 130 { 131 key: ['right'], 132 label: 'Next frame', 133 command: (output: ShortcutEventOutput) => this.moveFrame(+1), 134 }, 135 136 { 137 key: ['space'], 138 label: 'play/pause', 139 command: (output: ShortcutEventOutput) => this.togglePlay(), 140 }, 141 ]; 142} 143