/* * 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 { AbstractControl, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; import {TimelineData} from 'app/timeline_data'; import {assertDefined} from 'common/assert_utils'; import {FunctionUtils} from 'common/function_utils'; import {PersistentStore} from 'common/persistent_store'; import {StringUtils} from 'common/string_utils'; import {TimeRange, Timestamp} from 'common/time'; import {TimestampUtils} from 'common/timestamp_utils'; import {Analytics} from 'logging/analytics'; import { ActiveTraceChanged, ExpandedTimelineToggled, TracePositionUpdate, WinscopeEvent, WinscopeEventType, } from 'messaging/winscope_event'; import { EmitEvent, WinscopeEventEmitter, } from 'messaging/winscope_event_emitter'; import {WinscopeEventListener} from 'messaging/winscope_event_listener'; import {Trace} from 'trace/trace'; import {TRACE_INFO} from 'trace/trace_info'; import {TracePosition} from 'trace/trace_position'; import {TraceType, TraceTypeUtils} from 'trace/trace_type'; import {multlineTooltip} from 'viewers/components/styles/tooltip.styles'; import {MiniTimelineComponent} from './mini-timeline/mini_timeline_component'; @Component({ selector: 'timeline', encapsulation: ViewEncapsulation.None, template: `

No screenrecording frame to show

Current timestamp before first screenrecording frame.

`, styles: [ ` .navbar-toggle { display: flex; flex-direction: column; align-items: end; position: relative; } #toggle { width: fit-content; position: absolute; top: -41px; right: 0px; z-index: 1000; border: 1px solid #3333; border-bottom: 0px; border-right: 0px; border-top-left-radius: 6px; border-top-right-radius: 6px; background-color: var(--drawer-color); } .navbar { display: flex; width: 100%; flex-direction: row; align-items: center; justify-content: center; } #expanded-nav { display: flex; flex-direction: row; border-bottom: 1px solid #3333; border-top: 1px solid #3333; } #time-selector { display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 10px; margin-left: 0.5rem; height: 116px; width: 282px; background-color: var(--drawer-block-primary); } #time-selector .mat-form-field-wrapper { width: 100%; } #time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix { padding: 0 0.75rem 0 0.5rem !important; border-top: unset; } #time-selector .mat-form-field-flex, #time-selector .field-suffix { border-radius: 0; padding: 0; display: flex; align-items: center; } .bookmark-icon { cursor: pointer; } .time-selector-form { display: flex; flex-direction: column; height: 60px; width: 90%; justify-content: center; align-items: center; gap: 5px; } .time-selector-form mat-form-field { margin-bottom: -1.34375em; display: flex; width: 100%; font-size: 12px; } .time-selector-form input { text-overflow: ellipsis; font-weight: bold; } .time-selector-form .time-difference { padding-right: 2px; } #time-selector .time-controls { border-radius: 10px; margin: 0.5rem; display: flex; flex-direction: row; justify-content: space-between; width: 90%; background-color: var(--drawer-block-secondary); } #time-selector .mat-icon-button { width: 24px; height: 24px; padding-left: 3px; padding-right: 3px; } #time-selector .mat-icon { font-size: 18px; width: 18px; height: 18px; line-height: 18px; display: flex; } .shown-selection .trace-icon { font-size: 18px; width: 18px; height: 18px; padding-left: 4px; padding-right: 4px; padding-top: 2px; } #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-timeline { flex-grow: 1; } #trace-selector .mat-form-field-infix { width: 80px; } #trace-selector .shown-selection { height: 116px; border-radius: 10px; display: flex; justify-content: center; flex-wrap: wrap; align-content: flex-start; background-color: var(--drawer-block-primary); } #trace-selector .filter-header { padding-top: 4px; display: flex; gap: 2px; } .shown-selection .trace-icons { display: flex; justify-content: center; flex-wrap: wrap; align-content: flex-start; width: 70%; } #trace-selector .mat-select-trigger { height: unset; flex-direction: column-reverse; } #trace-selector .mat-select-arrow-wrapper { display: none; } #trace-selector .mat-form-field-wrapper { padding: 0; } :has(>.select-traces-panel) { max-height: unset !important; font-family: 'Roboto', sans-serif; position: relative; bottom: 120px; } .tip { padding: 16px; font-weight: 300; } .actions { 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; } `, multlineTooltip, ], }) export class TimelineComponent implements WinscopeEventEmitter, WinscopeEventListener { readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion'; readonly MAX_SELECTED_TRACES = 3; @Input() timelineData: TimelineData | undefined; @Input() store: PersistentStore | undefined; @Output() readonly collapsedTimelineSizeChanged = new EventEmitter(); @ViewChild('collapsedTimeline') private collapsedTimelineRef: | ElementRef | undefined; @ViewChild('miniTimeline') miniTimeline: MiniTimelineComponent | undefined; videoUrl: SafeUrl | undefined; initialZoom: TimeRange | undefined = undefined; selectedTraces: Array> = []; sortedAvailableTraces: Array> = []; selectedTracesFormControl = new FormControl>>([]); selectedTimeFormControl = new FormControl('undefined'); selectedNsFormControl = new FormControl( 'undefined', Validators.compose([Validators.required, this.validateNsFormat]), ); timestampForm = new FormGroup({ selectedTime: this.selectedTimeFormControl, selectedNs: this.selectedNsFormControl, }); TRACE_INFO = TRACE_INFO; isInputFormFocused = false; storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces'; bookmarks: Timestamp[] = []; private expanded = false; private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC; private expandedTimelineScrollEvent: WheelEvent | undefined; private expandedTimelineMouseXRatio: number | undefined; private seekTracePosition?: TracePosition; constructor( @Inject(DomSanitizer) private sanitizer: DomSanitizer, @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, ) {} ngOnInit() { const timelineData = assertDefined(this.timelineData); if (timelineData.hasTimestamps()) { this.updateTimeInputValuesToCurrentTimestamp(); } const converter = assertDefined(timelineData.getTimestampConverter()); const validatorFn: ValidatorFn = (control: AbstractControl) => { const valid = converter.validateHumanInput(control.value ?? ''); return !valid ? {invalidInput: control.value} : null; }; this.selectedTimeFormControl.addValidators( assertDefined(Validators.compose([Validators.required, validatorFn])), ); const screenRecordingVideo = timelineData.getScreenRecordingVideo(); if (screenRecordingVideo) { this.videoUrl = this.sanitizer.bypassSecurityTrustUrl( URL.createObjectURL(screenRecordingVideo), ); } // sorted to be displayed in order corresponding to viewer tabs this.sortedAvailableTraces = this.timelineData ?.getTraces() .mapTrace((trace) => trace) .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) ?? []; const storedDeselectedTraces = this.getStoredDeselectedTraceTypes(); this.selectedTraces = this.sortedAvailableTraces.filter((trace) => { return !storedDeselectedTraces.includes(trace.type); }); this.selectedTracesFormControl = new FormControl>>( this.selectedTraces, ); const initialTraceToCropZoom = this.sortedAvailableTraces.find((trace) => { return ( trace.type !== TraceType.SCREEN_RECORDING && TraceTypeUtils.isTraceTypeWithViewer(trace.type) && trace.lengthEntries > 0 ); }); if (initialTraceToCropZoom) { this.initialZoom = new TimeRange( initialTraceToCropZoom.getEntry(0).getTimestamp(), timelineData.getFullTimeRange().to, ); } } ngAfterViewInit() { const height = assertDefined(this.collapsedTimelineRef).nativeElement .offsetHeight; this.collapsedTimelineSizeChanged.emit(height); } setEmitEvent(callback: EmitEvent) { this.emitEvent = callback; } getVideoCurrentTime() { return assertDefined( this.timelineData, ).searchCorrespondingScreenRecordingTimeSeconds( this.getCurrentTracePosition(), ); } getCurrentTracePosition(): TracePosition { if (this.seekTracePosition) { return this.seekTracePosition; } const position = assertDefined(this.timelineData).getCurrentPosition(); if (position === undefined) { throw Error( 'A trace position should be available by the time the timeline is loaded', ); } return position; } getSelectedTracesToShow(): Array> { const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder(); return sortedSelectedTraces.length > 8 ? sortedSelectedTraces.slice(0, 7) : sortedSelectedTraces.slice(0, 8); } async onWinscopeEvent(event: WinscopeEvent) { await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => { this.updateTimeInputValuesToCurrentTimestamp(); }); await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => { await this.miniTimeline?.drawer?.draw(); this.updateSelectedTraces(event.trace); }); await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => { const activeTrace = this.timelineData?.getActiveTrace(); if (activeTrace === undefined) { return; } await this.miniTimeline?.drawer?.draw(); }); } async toggleExpand() { this.expanded = !this.expanded; this.changeDetectorRef.detectChanges(); if (this.expanded) { Analytics.Navigation.logExpandedTimelineOpened(); } await this.emitEvent(new ExpandedTimelineToggled(this.expanded)); } async updatePosition(position: TracePosition) { assertDefined(this.timelineData).setPosition(position); await this.emitEvent(new TracePositionUpdate(position)); } updateSeekTimestamp(timestamp: Timestamp | undefined) { if (timestamp) { this.seekTracePosition = assertDefined( this.timelineData, ).makePositionFromActiveTrace(timestamp); } else { this.seekTracePosition = undefined; } this.updateTimeInputValuesToCurrentTimestamp(); } isOptionDisabled(trace: Trace) { return this.timelineData?.getActiveTrace() === trace; } applyNewTraceSelection(clickedTrace: Trace) { this.selectedTraces = this.selectedTracesFormControl.value ?? this.sortedAvailableTraces; this.updateStoredDeselectedTraceTypes(clickedTrace); } @HostListener('document:focusin', ['$event']) handleFocusInEvent(event: FocusEvent) { if ( (event.target as HTMLInputElement)?.tagName === 'INPUT' && (event.target as HTMLInputElement)?.type === 'text' ) { //check if text input field focused this.isInputFormFocused = true; } } @HostListener('document:focusout', ['$event']) handleFocusOutEvent(event: FocusEvent) { if ( (event.target as HTMLInputElement)?.tagName === 'INPUT' && (event.target as HTMLInputElement)?.type === 'text' ) { //check if text input field focused this.isInputFormFocused = false; } } @HostListener('document:keydown', ['$event']) async handleKeyboardEvent(event: KeyboardEvent) { if ( this.isInputFormFocused || !assertDefined(this.timelineData).hasTimestamps() ) { return; } if (event.key === 'ArrowLeft') { await this.moveToPreviousEntry(); } else if (event.key === 'ArrowRight') { await this.moveToNextEntry(); } } hasPrevEntry(): boolean { const activeTrace = this.timelineData?.getActiveTrace(); if (!activeTrace) { return false; } return ( assertDefined(this.timelineData).getPreviousEntryFor(activeTrace) !== undefined ); } hasNextEntry(): boolean { const activeTrace = this.timelineData?.getActiveTrace(); if (!activeTrace) { return false; } return ( assertDefined(this.timelineData).getNextEntryFor(activeTrace) !== undefined ); } async moveToPreviousEntry() { const activeTrace = this.timelineData?.getActiveTrace(); if (!activeTrace) { return; } const timelineData = assertDefined(this.timelineData); timelineData.moveToPreviousEntryFor(activeTrace); const position = assertDefined(timelineData.getCurrentPosition()); await this.emitEvent(new TracePositionUpdate(position)); } async moveToNextEntry() { const activeTrace = this.timelineData?.getActiveTrace(); if (!activeTrace) { return; } const timelineData = assertDefined(this.timelineData); timelineData.moveToNextEntryFor(activeTrace); const position = assertDefined(timelineData.getCurrentPosition()); await this.emitEvent(new TracePositionUpdate(position)); } async onHumanTimeInputChange(event: Event) { if (event.type !== 'change' || !this.selectedTimeFormControl.valid) { return; } const target = event.target as HTMLInputElement; let input = target.value; // if hh:mm:ss.zz format, append date of current timestamp if (TimestampUtils.isRealTimeOnlyFormat(input)) { const date = assertDefined( TimestampUtils.extractDateFromHumanTimestamp( this.getCurrentTracePosition().timestamp.format(), ), ); input = date + 'T' + input; } const timelineData = assertDefined(this.timelineData); const timestamp = assertDefined( timelineData.getTimestampConverter(), ).makeTimestampFromHuman(input); Analytics.Navigation.logTimeInput('human'); await this.updatePosition( timelineData.makePositionFromActiveTrace(timestamp), ); this.updateTimeInputValuesToCurrentTimestamp(); } async onNanosecondsInputTimeChange(event: Event) { if (event.type !== 'change' || !this.selectedNsFormControl.valid) { return; } const target = event.target as HTMLInputElement; const timelineData = assertDefined(this.timelineData); const timestamp = assertDefined( timelineData.getTimestampConverter(), ).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value)); Analytics.Navigation.logTimeInput('ns'); await this.updatePosition( timelineData.makePositionFromActiveTrace(timestamp), ); this.updateTimeInputValuesToCurrentTimestamp(); } onKeydownEnterTimeInputField(event: KeyboardEvent) { if (this.selectedTimeFormControl.valid) { (event.target as HTMLInputElement).blur(); } } onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) { if (this.selectedNsFormControl.valid) { (event.target as HTMLInputElement).blur(); } } updateScrollEvent(event: WheelEvent) { this.expandedTimelineScrollEvent = event; } updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) { this.expandedTimelineMouseXRatio = mouseXRatio; } getCopyPositionTooltip(position: string): string { return `Copy current position:\n${position}`; } getHumanTimeTooltip(): string { const [date, time] = this.getCurrentTracePosition() .timestamp.format() .split(', '); return ` Date: ${date} Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()} Edit field to update position by inputting time as "hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz" `; } getCopyHumanTimeTooltip(): string { return this.getCopyPositionTooltip(this.getHumanTime()); } getHumanTime(): string { return this.getCurrentTracePosition().timestamp.format(); } onTimeCopied(type: 'ns' | 'human') { Analytics.Navigation.logTimeCopied(type); } getUTCOffset(): string { return assertDefined( this.timelineData?.getTimestampConverter(), ).getUTCOffset(); } currentPositionBookmarked(): boolean { const currentTimestampNs = this.getCurrentTracePosition().timestamp.getValueNs(); return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs); } toggleBookmarkCurrentPosition(event: PointerEvent) { const currentTimestamp = this.getCurrentTracePosition().timestamp; this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp)); event.stopPropagation(); } toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) { if (rangeContainsBookmark === undefined) { rangeContainsBookmark = this.bookmarks.some((bookmark) => range.containsTimestamp(bookmark), ); } const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n; if (rangeContainsBookmark) { const closestBookmark = this.bookmarks.reduce((prev, curr) => { if (clickedNs - curr.getValueNs() < 0) return prev; return Math.abs(Number(curr.getValueNs() - clickedNs)) < Math.abs(Number(prev.getValueNs() - clickedNs)) ? curr : prev; }); this.bookmarks = this.bookmarks.filter( (bm) => bm.getValueNs() !== closestBookmark.getValueNs(), ); } else { this.bookmarks = this.bookmarks.concat([ assertDefined( this.timelineData?.getTimestampConverter(), ).makeTimestampFromNs(clickedNs), ]); } } removeAllBookmarks() { this.bookmarks = []; } async onTimelineTraceClicked(trace: Trace) { await this.emitEvent(new ActiveTraceChanged(trace)); this.changeDetectorRef.detectChanges(); } private updateSelectedTraces(trace: Trace | undefined) { if (!trace) { return; } if (!this.selectedTraces.includes(trace)) { // Create new object to make sure we trigger an update on Mini Timeline child component this.selectedTraces = [...this.selectedTraces, trace]; this.selectedTracesFormControl.setValue(this.selectedTraces); } } private updateTimeInputValuesToCurrentTimestamp() { const currentTimestampNs = this.getCurrentTracePosition().timestamp.getValueNs(); const timelineData = assertDefined(this.timelineData); let formattedCurrentTimestamp = assertDefined( timelineData.getTimestampConverter(), ) .makeTimestampFromNs(currentTimestampNs) .format(); if (TimestampUtils.isHumanRealTimestampFormat(formattedCurrentTimestamp)) { formattedCurrentTimestamp = assertDefined( TimestampUtils.extractTimeFromHumanTimestamp(formattedCurrentTimestamp), ); } this.selectedTimeFormControl.setValue(formattedCurrentTimestamp); this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`); } private getSelectedTracesSortedByDisplayOrder(): Array> { return this.selectedTraces .slice() .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)); } private getStoredDeselectedTraceTypes(): TraceType[] { const storedDeselectedTraces = this.store?.get( this.storeKeyDeselectedTraces, ); return JSON.parse(storedDeselectedTraces ?? '[]'); } private updateStoredDeselectedTraceTypes(clickedTrace: Trace) { if (!this.store) { return; } let storedDeselected = this.getStoredDeselectedTraceTypes(); if ( this.selectedTraces.includes(clickedTrace) && storedDeselected.includes(clickedTrace.type) ) { storedDeselected = storedDeselected.filter( (stored) => stored !== clickedTrace.type, ); } else if ( !this.selectedTraces.includes(clickedTrace) && !storedDeselected.includes(clickedTrace.type) ) { Analytics.Navigation.logTraceTimelineDeselected( TRACE_INFO[clickedTrace.type].name, ); storedDeselected.push(clickedTrace.type); } this.store.add( this.storeKeyDeselectedTraces, JSON.stringify(storedDeselected), ); } private validateNsFormat(control: FormControl): ValidationErrors | null { const valid = TimestampUtils.isNsFormat(control.value ?? ''); return !valid ? {invalidInput: control.value} : null; } }