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