blob: a763c35f5482784f894161bf0d893624970e5979 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Inject,
Input,
Output,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {FormControl, FormGroup, Validators} from '@angular/forms';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {TimelineData} from 'app/timeline_data';
import {TRACE_INFO} from 'app/trace_info';
import {StringUtils} from 'common/string_utils';
import {TimeUtils} from 'common/time_utils';
import {TimestampChangeListener} from 'interfaces/timestamp_change_listener';
import {ElapsedTimestamp, RealTimestamp, Timestamp, TimestampType} from 'trace/timestamp';
import {TraceType} from 'trace/trace_type';
import {MiniTimelineComponent} from './mini_timeline_component';
@Component({
selector: 'timeline',
encapsulation: ViewEncapsulation.None,
template: `
<div id="expanded-nav" *ngIf="expanded">
<div id="video-content" *ngIf="videoUrl !== undefined">
<video
*ngIf="getVideoCurrentTime() !== undefined"
id="video"
[currentTime]="getVideoCurrentTime()"
[src]="videoUrl"></video>
<div *ngIf="getVideoCurrentTime() === undefined" class="no-video-message">
<p>No screenrecording frame to show</p>
<p>Current timestamp before first screenrecording frame.</p>
</div>
</div>
<expanded-timeline
[timelineData]="timelineData"
[currentTimestamp]="currentTimestamp"
(onTimestampChanged)="updateCurrentTimestamp($event)"
id="expanded-timeline"></expanded-timeline>
</div>
<div class="navbar" #collapsedTimeline>
<ng-template [ngIf]="timelineData.hasMoreThanOneDistinctTimestamp()">
<div id="time-selector">
<button
mat-icon-button
id="prev_entry_button"
color="primary"
(click)="moveToPreviousEntry()"
[disabled]="!hasPrevEntry()">
<mat-icon>chevron_left</mat-icon>
</button>
<form [formGroup]="timestampForm" class="time-selector-form">
<mat-form-field
class="time-input"
appearance="fill"
(change)="humanElapsedTimeInputChange($event)"
*ngIf="!usingRealtime()">
<input
matInput
name="humanElapsedTimeInput"
[formControl]="selectedElapsedTimeFormControl" />
</mat-form-field>
<mat-form-field
class="time-input"
appearance="fill"
(change)="humanRealTimeInputChanged($event)"
*ngIf="usingRealtime()">
<input
matInput
name="humanRealTimeInput"
[formControl]="selectedRealTimeFormControl" />
</mat-form-field>
<mat-form-field
class="time-input"
appearance="fill"
(change)="nanosecondsInputTimeChange($event)">
<input matInput name="nsTimeInput" [formControl]="selectedNsFormControl" />
</mat-form-field>
</form>
<button
mat-icon-button
id="next_entry_button"
color="primary"
(click)="moveToNextEntry()"
[disabled]="!hasNextEntry()">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div id="trace-selector">
<mat-form-field appearance="none">
<mat-select
#traceSelector
[formControl]="selectedTracesFormControl"
multiple
(closed)="onTraceSelectionClosed()">
<div class="tip">Select up to 2 additional traces to display.</div>
<mat-option
*ngFor="let trace of availableTraces"
[value]="trace"
[style]="{
color: TRACE_INFO[trace].color,
opacity: isOptionDisabled(trace) ? 0.5 : 1.0
}"
[disabled]="isOptionDisabled(trace)">
<mat-icon>{{ TRACE_INFO[trace].icon }}</mat-icon>
{{ TRACE_INFO[trace].name }}
</mat-option>
<div class="actions">
<button mat-button color="primary" (click)="traceSelector.close()">Cancel</button>
<button
mat-flat-button
color="primary"
(click)="applyNewTraceSelection(); traceSelector.close()">
Apply
</button>
</div>
<mat-select-trigger class="shown-selection">
<mat-icon
*ngFor="let selectedTrace of selectedTraces"
[style]="{color: TRACE_INFO[selectedTrace].color}">
{{ TRACE_INFO[selectedTrace].icon }}
</mat-icon>
</mat-select-trigger>
</mat-select>
</mat-form-field>
</div>
<mini-timeline
[timelineData]="timelineData"
[currentTimestamp]="currentTimestamp"
[selectedTraces]="selectedTraces"
(changeTimestamp)="updateCurrentTimestamp($event)"
(changeSeekTimestamp)="updateSeekTimestamp($event)"
id="mini-timeline"
#miniTimeline></mini-timeline>
<div id="toggle" *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()">
<button
mat-icon-button
[class]="TOGGLE_BUTTON_CLASS"
color="primary"
aria-label="Toggle Expanded Timeline"
(click)="toggleExpand()">
<mat-icon *ngIf="!expanded">expand_less</mat-icon>
<mat-icon *ngIf="expanded">expand_more</mat-icon>
</button>
</div>
</ng-template>
<div *ngIf="!timelineData.hasTimestamps()" class="no-timestamps-msg">
<p class="mat-body-2">No timeline to show!</p>
<p class="mat-body-1">All loaded traces contain no timestamps!</p>
</div>
<div
*ngIf="timelineData.hasTimestamps() && !timelineData.hasMoreThanOneDistinctTimestamp()"
class="no-timestamps-msg">
<p class="mat-body-2">No timeline to show!</p>
<p class="mat-body-1">Only a single timestamp has been recorded.</p>
</div>
</div>
`,
styles: [
`
.navbar {
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: center;
}
#expanded-nav {
display: flex;
border-bottom: 1px solid #3333;
}
#time-selector {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.time-selector-form {
display: flex;
flex-direction: column;
width: 15em;
}
.time-selector-form .time-input {
width: 100%;
margin-bottom: -1.34375em;
text-align: center;
}
#mini-timeline {
flex-grow: 1;
align-self: stretch;
}
#video-content {
position: relative;
min-width: 20rem;
min-height: 35rem;
align-self: stretch;
text-align: center;
border: 2px solid black;
flex-basis: 0px;
flex-grow: 1;
display: flex;
align-items: center;
}
#video {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
}
#expanded-nav {
display: flex;
flex-direction: row;
}
#expanded-timeline {
flex-grow: 1;
}
#trace-selector .mat-form-field-infix {
width: 50px;
padding: 0 0.75rem 0 0.5rem;
border-top: unset;
}
#trace-selector .mat-icon {
padding: 2px;
}
#trace-selector .shown-selection {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: auto;
}
#trace-selector .mat-select-trigger {
height: unset;
}
#trace-selector .mat-form-field-wrapper {
padding: 0;
}
.mat-select-panel {
max-height: unset !important;
font-family: 'Roboto', sans-serif;
}
.tip {
padding: 1.5rem;
font-weight: 200;
border-bottom: solid 1px #dadce0;
}
.actions {
border-top: solid 1px #dadce0;
width: 100%;
padding: 1.5rem;
float: right;
display: flex;
justify-content: flex-end;
}
.no-video-message {
padding: 1rem;
font-family: 'Roboto', sans-serif;
}
.no-timestamps-msg {
padding: 1rem;
align-items: center;
display: flex;
flex-direction: column;
}
`,
],
})
export class TimelineComponent implements TimestampChangeListener {
readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion';
readonly MAX_SELECTED_TRACES = 3;
@Input() set activeViewTraceTypes(types: TraceType[] | undefined) {
if (!types) {
return;
}
if (types.length !== 1) {
throw Error("Timeline component doesn't support viewers with dependencies length !== 1");
}
this.internalActiveTrace = types[0];
if (!this.selectedTraces.includes(this.internalActiveTrace)) {
this.selectedTraces.push(this.internalActiveTrace);
}
if (this.selectedTraces.length > this.MAX_SELECTED_TRACES) {
// Maxed capacity so remove oldest selected trace
this.selectedTraces = this.selectedTraces.slice(1, 1 + this.MAX_SELECTED_TRACES);
}
this.selectedTracesFormControl.setValue(this.selectedTraces);
}
internalActiveTrace: TraceType | undefined = undefined;
@Input() timelineData!: TimelineData;
@Input() availableTraces: TraceType[] = [];
@Output() collapsedTimelineSizeChanged = new EventEmitter<number>();
@ViewChild('miniTimeline') private miniTimelineComponent!: MiniTimelineComponent;
@ViewChild('collapsedTimeline') private collapsedTimelineRef!: ElementRef;
selectedTraces: TraceType[] = [];
selectedTracesFormControl = new FormControl();
selectedElapsedTimeFormControl = new FormControl(
'undefined',
Validators.compose([
Validators.required,
Validators.pattern(TimeUtils.HUMAN_ELAPSED_TIMESTAMP_REGEX),
])
);
selectedRealTimeFormControl = new FormControl(
'undefined',
Validators.compose([
Validators.required,
Validators.pattern(TimeUtils.HUMAN_REAL_TIMESTAMP_REGEX),
])
);
selectedNsFormControl = new FormControl(
'undefined',
Validators.compose([Validators.required, Validators.pattern(TimeUtils.NS_TIMESTAMP_REGEX)])
);
timestampForm = new FormGroup({
selectedElapsedTime: this.selectedElapsedTimeFormControl,
selectedRealTime: this.selectedRealTimeFormControl,
selectedNs: this.selectedNsFormControl,
});
videoUrl: SafeUrl | undefined;
private expanded = false;
TRACE_INFO = TRACE_INFO;
constructor(
@Inject(DomSanitizer) private sanitizer: DomSanitizer,
@Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef
) {}
ngOnInit() {
if (this.timelineData.hasTimestamps()) {
this.updateTimeInputValuesToCurrentTimestamp();
}
const screenRecordingVideo = this.timelineData.getScreenRecordingVideo();
if (screenRecordingVideo) {
this.videoUrl = this.sanitizer.bypassSecurityTrustUrl(
URL.createObjectURL(screenRecordingVideo)
);
}
}
ngAfterViewInit() {
const height = this.collapsedTimelineRef.nativeElement.offsetHeight;
this.collapsedTimelineSizeChanged.emit(height);
}
getVideoCurrentTime() {
return this.timelineData.searchCorrespondingScreenRecordingTimeSeconds(this.currentTimestamp);
}
private seekTimestamp: Timestamp | undefined;
get currentTimestamp(): Timestamp {
if (this.seekTimestamp !== undefined) {
return this.seekTimestamp;
}
const timestamp = this.timelineData.getCurrentTimestamp();
if (timestamp === undefined) {
throw Error('A timestamp should have been set by the time the timeline is loaded');
}
return timestamp;
}
onCurrentTimestampChanged(timestamp: Timestamp | undefined): void {
if (!timestamp) {
return;
}
this.updateTimeInputValuesToCurrentTimestamp();
}
toggleExpand() {
this.expanded = !this.expanded;
this.changeDetectorRef.detectChanges();
}
updateCurrentTimestamp(timestamp: Timestamp) {
this.timelineData.setCurrentTimestamp(timestamp);
}
usingRealtime(): boolean {
return this.timelineData.getTimestampType() === TimestampType.REAL;
}
updateSeekTimestamp(timestamp: Timestamp | undefined) {
this.seekTimestamp = timestamp;
this.updateTimeInputValuesToCurrentTimestamp();
}
private updateTimeInputValuesToCurrentTimestamp() {
this.selectedElapsedTimeFormControl.setValue(
TimeUtils.format(new ElapsedTimestamp(this.currentTimestamp.getValueNs()), false)
);
this.selectedRealTimeFormControl.setValue(
TimeUtils.format(new RealTimestamp(this.currentTimestamp.getValueNs()))
);
this.selectedNsFormControl.setValue(`${this.currentTimestamp.getValueNs()} ns`);
}
isOptionDisabled(trace: TraceType) {
if (this.internalActiveTrace === trace) {
return true;
}
// Reached limit of options and is not a selected element
if (
(this.selectedTracesFormControl.value?.length ?? 0) >= this.MAX_SELECTED_TRACES &&
this.selectedTracesFormControl.value?.find((el: TraceType) => el === trace) === undefined
) {
return true;
}
return false;
}
onTraceSelectionClosed() {
this.selectedTracesFormControl.setValue(this.selectedTraces);
}
applyNewTraceSelection() {
this.selectedTraces = this.selectedTracesFormControl.value;
}
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
if (event.key === 'ArrowLeft') {
this.moveToPreviousEntry();
} else if (event.key === 'ArrowRight') {
this.moveToNextEntry();
}
}
hasPrevEntry(): boolean {
if (
!this.internalActiveTrace ||
(this.timelineData.getTimelines().get(this.internalActiveTrace)?.length ?? 0) === 0
) {
return false;
}
return this.timelineData.getPreviousTimestampFor(this.internalActiveTrace) !== undefined;
}
hasNextEntry(): boolean {
if (
!this.internalActiveTrace ||
(this.timelineData.getTimelines().get(this.internalActiveTrace)?.length ?? 0) === 0
) {
return false;
}
return this.timelineData.getNextTimestampFor(this.internalActiveTrace) !== undefined;
}
moveToPreviousEntry() {
if (!this.internalActiveTrace) {
return;
}
this.timelineData.moveToPreviousTimestampFor(this.internalActiveTrace);
}
moveToNextEntry() {
if (!this.internalActiveTrace) {
return;
}
this.timelineData.moveToNextTimestampFor(this.internalActiveTrace);
}
humanElapsedTimeInputChange(event: Event) {
if (event.type !== 'change') {
return;
}
const target = event.target as HTMLInputElement;
const timestamp = TimeUtils.parseHumanElapsed(target.value);
this.timelineData.setCurrentTimestamp(timestamp);
this.updateTimeInputValuesToCurrentTimestamp();
}
humanRealTimeInputChanged(event: Event) {
if (event.type !== 'change') {
return;
}
const target = event.target as HTMLInputElement;
const timestamp = TimeUtils.parseHumanReal(target.value);
this.timelineData.setCurrentTimestamp(timestamp);
this.updateTimeInputValuesToCurrentTimestamp();
}
nanosecondsInputTimeChange(event: Event) {
if (event.type !== 'change') {
return;
}
const target = event.target as HTMLInputElement;
const timestamp = new Timestamp(
this.timelineData.getTimestampType()!,
StringUtils.parseBigIntStrippingUnit(target.value)
);
this.timelineData.setCurrentTimestamp(timestamp);
this.updateTimeInputValuesToCurrentTimestamp();
}
}