1/* 2 * Copyright (C) 2022 The Android Open Source Project 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 ChangeDetectorRef, 19 Component, 20 ElementRef, 21 EventEmitter, 22 HostListener, 23 Inject, 24 Input, 25 Output, 26 ViewChild, 27 ViewEncapsulation, 28} from '@angular/core'; 29import {FormControl, FormGroup, Validators} from '@angular/forms'; 30import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; 31import {TimelineData} from 'app/timeline_data'; 32import {TRACE_INFO} from 'app/trace_info'; 33import {assertDefined} from 'common/assert_utils'; 34import {FunctionUtils} from 'common/function_utils'; 35import {StringUtils} from 'common/string_utils'; 36import {TimeUtils} from 'common/time_utils'; 37import { 38 OnTracePositionUpdate, 39 TracePositionUpdateEmitter, 40} from 'interfaces/trace_position_update_emitter'; 41import {TracePositionUpdateListener} from 'interfaces/trace_position_update_listener'; 42import {ElapsedTimestamp, RealTimestamp, Timestamp, TimestampType} from 'trace/timestamp'; 43import {TracePosition} from 'trace/trace_position'; 44import {TraceType} from 'trace/trace_type'; 45import {MiniTimelineComponent} from './mini_timeline_component'; 46 47@Component({ 48 selector: 'timeline', 49 encapsulation: ViewEncapsulation.None, 50 template: ` 51 <div id="expanded-nav" *ngIf="expanded"> 52 <div id="video-content" *ngIf="videoUrl !== undefined"> 53 <video 54 *ngIf="getVideoCurrentTime() !== undefined" 55 id="video" 56 [currentTime]="getVideoCurrentTime()" 57 [src]="videoUrl"></video> 58 <div *ngIf="getVideoCurrentTime() === undefined" class="no-video-message"> 59 <p>No screenrecording frame to show</p> 60 <p>Current timestamp before first screenrecording frame.</p> 61 </div> 62 </div> 63 <expanded-timeline 64 [timelineData]="timelineData" 65 (onTracePositionUpdate)="updatePosition($event)" 66 id="expanded-timeline"></expanded-timeline> 67 </div> 68 <div class="navbar" #collapsedTimeline> 69 <ng-template [ngIf]="timelineData.hasMoreThanOneDistinctTimestamp()"> 70 <div id="time-selector"> 71 <button 72 mat-icon-button 73 id="prev_entry_button" 74 color="primary" 75 (click)="moveToPreviousEntry()" 76 [disabled]="!hasPrevEntry()"> 77 <mat-icon>chevron_left</mat-icon> 78 </button> 79 <form [formGroup]="timestampForm" class="time-selector-form"> 80 <mat-form-field 81 class="time-input" 82 appearance="fill" 83 (change)="humanElapsedTimeInputChange($event)" 84 *ngIf="!usingRealtime()"> 85 <input 86 matInput 87 name="humanElapsedTimeInput" 88 [formControl]="selectedElapsedTimeFormControl" /> 89 </mat-form-field> 90 <mat-form-field 91 class="time-input" 92 appearance="fill" 93 (change)="humanRealTimeInputChanged($event)" 94 *ngIf="usingRealtime()"> 95 <input 96 matInput 97 name="humanRealTimeInput" 98 [formControl]="selectedRealTimeFormControl" /> 99 </mat-form-field> 100 <mat-form-field 101 class="time-input" 102 appearance="fill" 103 (change)="nanosecondsInputTimeChange($event)"> 104 <input matInput name="nsTimeInput" [formControl]="selectedNsFormControl" /> 105 </mat-form-field> 106 </form> 107 <button 108 mat-icon-button 109 id="next_entry_button" 110 color="primary" 111 (click)="moveToNextEntry()" 112 [disabled]="!hasNextEntry()"> 113 <mat-icon>chevron_right</mat-icon> 114 </button> 115 </div> 116 <div id="trace-selector"> 117 <mat-form-field appearance="none"> 118 <mat-select 119 #traceSelector 120 [formControl]="selectedTracesFormControl" 121 multiple 122 (closed)="onTraceSelectionClosed()"> 123 <div class="tip">Select up to 2 additional traces to display.</div> 124 <mat-option 125 *ngFor="let trace of availableTraces" 126 [value]="trace" 127 [style]="{ 128 color: TRACE_INFO[trace].color, 129 opacity: isOptionDisabled(trace) ? 0.5 : 1.0 130 }" 131 [disabled]="isOptionDisabled(trace)"> 132 <mat-icon>{{ TRACE_INFO[trace].icon }}</mat-icon> 133 {{ TRACE_INFO[trace].name }} 134 </mat-option> 135 <div class="actions"> 136 <button mat-button color="primary" (click)="traceSelector.close()">Cancel</button> 137 <button 138 mat-flat-button 139 color="primary" 140 (click)="applyNewTraceSelection(); traceSelector.close()"> 141 Apply 142 </button> 143 </div> 144 <mat-select-trigger class="shown-selection"> 145 <mat-icon 146 *ngFor="let selectedTrace of selectedTraces" 147 [style]="{color: TRACE_INFO[selectedTrace].color}"> 148 {{ TRACE_INFO[selectedTrace].icon }} 149 </mat-icon> 150 </mat-select-trigger> 151 </mat-select> 152 </mat-form-field> 153 </div> 154 <mini-timeline 155 [timelineData]="timelineData" 156 [currentTracePosition]="getCurrentTracePosition()" 157 [selectedTraces]="selectedTraces" 158 (onTracePositionUpdate)="updatePosition($event)" 159 (onSeekTimestampUpdate)="updateSeekTimestamp($event)" 160 id="mini-timeline" 161 #miniTimeline></mini-timeline> 162 <div id="toggle" *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()"> 163 <button 164 mat-icon-button 165 [class]="TOGGLE_BUTTON_CLASS" 166 color="primary" 167 aria-label="Toggle Expanded Timeline" 168 (click)="toggleExpand()"> 169 <mat-icon *ngIf="!expanded">expand_less</mat-icon> 170 <mat-icon *ngIf="expanded">expand_more</mat-icon> 171 </button> 172 </div> 173 </ng-template> 174 <div *ngIf="!timelineData.hasTimestamps()" class="no-timestamps-msg"> 175 <p class="mat-body-2">No timeline to show!</p> 176 <p class="mat-body-1">All loaded traces contain no timestamps!</p> 177 </div> 178 <div 179 *ngIf="timelineData.hasTimestamps() && !timelineData.hasMoreThanOneDistinctTimestamp()" 180 class="no-timestamps-msg"> 181 <p class="mat-body-2">No timeline to show!</p> 182 <p class="mat-body-1">Only a single timestamp has been recorded.</p> 183 </div> 184 </div> 185 `, 186 styles: [ 187 ` 188 .navbar { 189 display: flex; 190 width: 100%; 191 flex-direction: row; 192 align-items: center; 193 justify-content: center; 194 } 195 #expanded-nav { 196 display: flex; 197 border-bottom: 1px solid #3333; 198 } 199 #time-selector { 200 display: flex; 201 flex-direction: row; 202 align-items: center; 203 justify-content: center; 204 } 205 .time-selector-form { 206 display: flex; 207 flex-direction: column; 208 width: 15em; 209 } 210 .time-selector-form .time-input { 211 width: 100%; 212 margin-bottom: -1.34375em; 213 text-align: center; 214 } 215 #mini-timeline { 216 flex-grow: 1; 217 align-self: stretch; 218 } 219 #video-content { 220 position: relative; 221 min-width: 20rem; 222 min-height: 35rem; 223 align-self: stretch; 224 text-align: center; 225 border: 2px solid black; 226 flex-basis: 0px; 227 flex-grow: 1; 228 display: flex; 229 align-items: center; 230 } 231 #video { 232 position: absolute; 233 left: 0; 234 top: 0; 235 height: 100%; 236 width: 100%; 237 } 238 #expanded-nav { 239 display: flex; 240 flex-direction: row; 241 } 242 #expanded-timeline { 243 flex-grow: 1; 244 } 245 #trace-selector .mat-form-field-infix { 246 width: 50px; 247 padding: 0 0.75rem 0 0.5rem; 248 border-top: unset; 249 } 250 #trace-selector .mat-icon { 251 padding: 2px; 252 } 253 #trace-selector .shown-selection { 254 display: flex; 255 flex-direction: column; 256 justify-content: center; 257 align-items: center; 258 height: auto; 259 } 260 #trace-selector .mat-select-trigger { 261 height: unset; 262 } 263 #trace-selector .mat-form-field-wrapper { 264 padding: 0; 265 } 266 .mat-select-panel { 267 max-height: unset !important; 268 font-family: 'Roboto', sans-serif; 269 } 270 .tip { 271 padding: 1.5rem; 272 font-weight: 200; 273 border-bottom: solid 1px #dadce0; 274 } 275 .actions { 276 border-top: solid 1px #dadce0; 277 width: 100%; 278 padding: 1.5rem; 279 float: right; 280 display: flex; 281 justify-content: flex-end; 282 } 283 .no-video-message { 284 padding: 1rem; 285 font-family: 'Roboto', sans-serif; 286 } 287 .no-timestamps-msg { 288 padding: 1rem; 289 align-items: center; 290 display: flex; 291 flex-direction: column; 292 } 293 `, 294 ], 295}) 296export class TimelineComponent implements TracePositionUpdateEmitter, TracePositionUpdateListener { 297 readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion'; 298 readonly MAX_SELECTED_TRACES = 3; 299 300 @Input() set activeViewTraceTypes(types: TraceType[] | undefined) { 301 if (!types) { 302 return; 303 } 304 305 if (types.length !== 1) { 306 throw Error("Timeline component doesn't support viewers with dependencies length !== 1"); 307 } 308 309 this.internalActiveTrace = types[0]; 310 311 if (!this.selectedTraces.includes(this.internalActiveTrace)) { 312 this.selectedTraces.push(this.internalActiveTrace); 313 } 314 315 if (this.selectedTraces.length > this.MAX_SELECTED_TRACES) { 316 // Maxed capacity so remove oldest selected trace 317 this.selectedTraces = this.selectedTraces.slice(1, 1 + this.MAX_SELECTED_TRACES); 318 } 319 320 // Create new object to make sure we trigger an update on Mini Timeline child component 321 this.selectedTraces = [...this.selectedTraces]; 322 this.selectedTracesFormControl.setValue(this.selectedTraces); 323 } 324 internalActiveTrace: TraceType | undefined = undefined; 325 326 @Input() timelineData!: TimelineData; 327 @Input() availableTraces: TraceType[] = []; 328 329 @Output() collapsedTimelineSizeChanged = new EventEmitter<number>(); 330 331 @ViewChild('miniTimeline') private miniTimelineComponent!: MiniTimelineComponent; 332 @ViewChild('collapsedTimeline') private collapsedTimelineRef!: ElementRef; 333 334 selectedTraces: TraceType[] = []; 335 selectedTracesFormControl = new FormControl(); 336 337 selectedElapsedTimeFormControl = new FormControl( 338 'undefined', 339 Validators.compose([ 340 Validators.required, 341 Validators.pattern(TimeUtils.HUMAN_ELAPSED_TIMESTAMP_REGEX), 342 ]) 343 ); 344 selectedRealTimeFormControl = new FormControl( 345 'undefined', 346 Validators.compose([ 347 Validators.required, 348 Validators.pattern(TimeUtils.HUMAN_REAL_TIMESTAMP_REGEX), 349 ]) 350 ); 351 selectedNsFormControl = new FormControl( 352 'undefined', 353 Validators.compose([Validators.required, Validators.pattern(TimeUtils.NS_TIMESTAMP_REGEX)]) 354 ); 355 timestampForm = new FormGroup({ 356 selectedElapsedTime: this.selectedElapsedTimeFormControl, 357 selectedRealTime: this.selectedRealTimeFormControl, 358 selectedNs: this.selectedNsFormControl, 359 }); 360 361 videoUrl: SafeUrl | undefined; 362 363 private expanded = false; 364 365 TRACE_INFO = TRACE_INFO; 366 367 private onTracePositionUpdateCallback: OnTracePositionUpdate = FunctionUtils.DO_NOTHING_ASYNC; 368 369 constructor( 370 @Inject(DomSanitizer) private sanitizer: DomSanitizer, 371 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef 372 ) {} 373 374 ngOnInit() { 375 if (this.timelineData.hasTimestamps()) { 376 this.updateTimeInputValuesToCurrentTimestamp(); 377 } 378 379 const screenRecordingVideo = this.timelineData.getScreenRecordingVideo(); 380 if (screenRecordingVideo) { 381 this.videoUrl = this.sanitizer.bypassSecurityTrustUrl( 382 URL.createObjectURL(screenRecordingVideo) 383 ); 384 } 385 } 386 387 ngAfterViewInit() { 388 const height = this.collapsedTimelineRef.nativeElement.offsetHeight; 389 this.collapsedTimelineSizeChanged.emit(height); 390 } 391 392 setOnTracePositionUpdate(callback: OnTracePositionUpdate) { 393 this.onTracePositionUpdateCallback = callback; 394 } 395 396 getVideoCurrentTime() { 397 return this.timelineData.searchCorrespondingScreenRecordingTimeSeconds( 398 this.getCurrentTracePosition() 399 ); 400 } 401 402 private seekTracePosition?: TracePosition; 403 404 getCurrentTracePosition(): TracePosition { 405 if (this.seekTracePosition) { 406 return this.seekTracePosition; 407 } 408 409 const position = this.timelineData.getCurrentPosition(); 410 if (position === undefined) { 411 throw Error('A trace position should be available by the time the timeline is loaded'); 412 } 413 414 return position; 415 } 416 417 onTracePositionUpdate(position: TracePosition) { 418 this.updateTimeInputValuesToCurrentTimestamp(); 419 } 420 421 toggleExpand() { 422 this.expanded = !this.expanded; 423 this.changeDetectorRef.detectChanges(); 424 } 425 426 async updatePosition(position: TracePosition) { 427 this.timelineData.setPosition(position); 428 await this.onTracePositionUpdateCallback(position); 429 } 430 431 usingRealtime(): boolean { 432 return this.timelineData.getTimestampType() === TimestampType.REAL; 433 } 434 435 updateSeekTimestamp(timestamp: Timestamp | undefined) { 436 if (timestamp) { 437 this.seekTracePosition = TracePosition.fromTimestamp(timestamp); 438 } else { 439 this.seekTracePosition = undefined; 440 } 441 this.updateTimeInputValuesToCurrentTimestamp(); 442 } 443 444 private updateTimeInputValuesToCurrentTimestamp() { 445 this.selectedElapsedTimeFormControl.setValue( 446 TimeUtils.format( 447 new ElapsedTimestamp(this.getCurrentTracePosition().timestamp.getValueNs()), 448 false 449 ) 450 ); 451 this.selectedRealTimeFormControl.setValue( 452 TimeUtils.format(new RealTimestamp(this.getCurrentTracePosition().timestamp.getValueNs())) 453 ); 454 this.selectedNsFormControl.setValue( 455 `${this.getCurrentTracePosition().timestamp.getValueNs()} ns` 456 ); 457 } 458 459 isOptionDisabled(trace: TraceType) { 460 if (this.internalActiveTrace === trace) { 461 return true; 462 } 463 464 // Reached limit of options and is not a selected element 465 if ( 466 (this.selectedTracesFormControl.value?.length ?? 0) >= this.MAX_SELECTED_TRACES && 467 this.selectedTracesFormControl.value?.find((el: TraceType) => el === trace) === undefined 468 ) { 469 return true; 470 } 471 472 return false; 473 } 474 475 onTraceSelectionClosed() { 476 this.selectedTracesFormControl.setValue(this.selectedTraces); 477 } 478 479 applyNewTraceSelection() { 480 this.selectedTraces = this.selectedTracesFormControl.value; 481 } 482 483 @HostListener('document:keydown', ['$event']) 484 async handleKeyboardEvent(event: KeyboardEvent) { 485 if (event.key === 'ArrowLeft') { 486 await this.moveToPreviousEntry(); 487 } else if (event.key === 'ArrowRight') { 488 await this.moveToNextEntry(); 489 } 490 } 491 492 hasPrevEntry(): boolean { 493 if (!this.internalActiveTrace) { 494 return false; 495 } 496 if (this.timelineData.getTraces().getTrace(this.internalActiveTrace) === undefined) { 497 return false; 498 } 499 return this.timelineData.getPreviousEntryFor(this.internalActiveTrace) !== undefined; 500 } 501 502 hasNextEntry(): boolean { 503 if (!this.internalActiveTrace) { 504 return false; 505 } 506 if (this.timelineData.getTraces().getTrace(this.internalActiveTrace) === undefined) { 507 return false; 508 } 509 return this.timelineData.getNextEntryFor(this.internalActiveTrace) !== undefined; 510 } 511 512 async moveToPreviousEntry() { 513 if (!this.internalActiveTrace) { 514 return; 515 } 516 this.timelineData.moveToPreviousEntryFor(this.internalActiveTrace); 517 await this.onTracePositionUpdateCallback(assertDefined(this.timelineData.getCurrentPosition())); 518 } 519 520 async moveToNextEntry() { 521 if (!this.internalActiveTrace) { 522 return; 523 } 524 this.timelineData.moveToNextEntryFor(this.internalActiveTrace); 525 await this.onTracePositionUpdateCallback(assertDefined(this.timelineData.getCurrentPosition())); 526 } 527 528 async humanElapsedTimeInputChange(event: Event) { 529 if (event.type !== 'change') { 530 return; 531 } 532 const target = event.target as HTMLInputElement; 533 const timestamp = TimeUtils.parseHumanElapsed(target.value); 534 await this.updatePosition(TracePosition.fromTimestamp(timestamp)); 535 this.updateTimeInputValuesToCurrentTimestamp(); 536 } 537 538 async humanRealTimeInputChanged(event: Event) { 539 if (event.type !== 'change') { 540 return; 541 } 542 const target = event.target as HTMLInputElement; 543 544 const timestamp = TimeUtils.parseHumanReal(target.value); 545 await this.updatePosition(TracePosition.fromTimestamp(timestamp)); 546 this.updateTimeInputValuesToCurrentTimestamp(); 547 } 548 549 async nanosecondsInputTimeChange(event: Event) { 550 if (event.type !== 'change') { 551 return; 552 } 553 const target = event.target as HTMLInputElement; 554 555 const timestamp = new Timestamp( 556 this.timelineData.getTimestampType()!, 557 StringUtils.parseBigIntStrippingUnit(target.value) 558 ); 559 await this.updatePosition(TracePosition.fromTimestamp(timestamp)); 560 this.updateTimeInputValuesToCurrentTimestamp(); 561 } 562} 563