• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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