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 */ 16import { 17 ChangeDetectorRef, 18 Component, 19 ElementRef, 20 HostListener, 21 Inject, 22 Input, 23 NgZone, 24 SimpleChanges, 25} from '@angular/core'; 26import {MatSelectChange} from '@angular/material/select'; 27import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; 28import {assertDefined} from 'common/assert_utils'; 29import {Size} from 'common/geometry/size'; 30import {MediaBasedTraceEntry} from 'trace/media_based_trace_entry'; 31 32@Component({ 33 selector: 'viewer-media-based', 34 template: ` 35 <div class="overlay"> 36 <mat-card class="container" cdkDrag cdkDragBoundary=".overlay"> 37 <mat-card-title class="header"> 38 <button mat-button class="button-drag draggable" cdkDragHandle> 39 <mat-icon class="drag-icon">drag_indicator</mat-icon> 40 </button> 41 <span 42 #titleText 43 *ngIf="titles.length <= 1" 44 cdkDragHandle 45 class="mat-body-2 overlay-title draggable" 46 [matTooltip]="titles.at(index)" 47 matTooltipPosition="above" 48 [matTooltipShowDelay]="300" 49 >{{ titles.at(0)?.split(".")[0].split(" ")[0] ?? 'Screen recording'}}</span> 50 51 <mat-select 52 *ngIf="titles.length > 1" 53 class="overlay-title select-title" 54 [matTooltip]="titles.at(index)" 55 matTooltipPosition="above" 56 [matTooltipShowDelay]="300" 57 (selectionChange)="onSelectChange($event)" 58 [value]="index"> 59 <mat-option 60 *ngFor="let title of titles; index as i" 61 [value]="i"> 62 {{ titles[i].split(".")[0] }} 63 </mat-option> 64 </mat-select> 65 66 <button mat-button class="button-minimize" [disabled]="forceMinimize" (click)="onMinimizeButtonClick()"> 67 <mat-icon> 68 {{ isMinimized() ? 'maximize' : 'minimize' }} 69 </mat-icon> 70 </button> 71 </mat-card-title> 72 <div class="video-container" cdkDragHandle [style.height]="isMinimized() ? '0px' : ''"> 73 <ng-container *ngIf="hasFrameToShow(); then video; else noVideo"> </ng-container> 74 </div> 75 </mat-card> 76 77 <ng-template #video> 78 <video 79 *ngIf="hasFrameToShow()" 80 [currentTime]="getCurrentTime()" 81 [src]="safeUrl" 82 #videoElement></video> 83 </ng-template> 84 85 <ng-template #noVideo> 86 <img *ngIf="hasImage()" [src]="safeUrl" /> 87 88 <div class="no-video" *ngIf="!hasImage()"> 89 <p class="mat-body-2">No frame to show.</p> 90 </div> 91 </ng-template> 92 </div> 93 `, 94 styles: [ 95 ` 96 .overlay { 97 z-index: 30; 98 position: fixed; 99 top: 0px; 100 left: 0px; 101 width: 100%; 102 height: 100%; 103 pointer-events: none; 104 } 105 106 .container { 107 pointer-events: all; 108 width: max(250px, 15vw); 109 min-width: 200px; 110 resize: horizontal; 111 overflow: hidden; 112 display: flex; 113 flex-direction: column; 114 padding: 0; 115 left: 80vw; 116 top: 20vh; 117 } 118 119 .header { 120 display: flex; 121 flex-direction: row; 122 margin: 0px; 123 border: 1px solid var(--border-color); 124 border-radius: 4px; 125 justify-content: space-between; 126 align-items: center; 127 } 128 129 .draggable { 130 cursor: grab; 131 } 132 133 .button-drag { 134 padding: 2px; 135 min-width: fit-content; 136 } 137 138 .overlay-title { 139 overflow: hidden; 140 text-overflow: ellipsis; 141 font-size: 14px; 142 width: unset; 143 } 144 145 .select-title { 146 display: flex; 147 align-items: center; 148 } 149 150 .button-minimize { 151 flex-grow: 0; 152 padding: 2px; 153 min-width: 24px; 154 } 155 156 .video-container, video, img { 157 border: 1px solid var(--default-border); 158 width: 100%; 159 height: auto; 160 cursor: grab; 161 overflow: hidden; 162 } 163 164 .no-video { 165 padding: 1rem; 166 text-align: center; 167 } 168 `, 169 ], 170}) 171class ViewerMediaBasedComponent { 172 safeUrl: undefined | SafeUrl = undefined; 173 shouldMinimize = false; 174 index = 0; 175 176 constructor( 177 @Inject(DomSanitizer) private sanitizer: DomSanitizer, 178 @Inject(ElementRef) private elementRef: ElementRef<HTMLElement>, 179 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 180 @Inject(NgZone) private ngZone: NgZone, 181 ) {} 182 183 @Input() currentTraceEntries: MediaBasedTraceEntry[] = []; 184 @Input() titles: string[] = []; 185 @Input() forceMinimize = false; 186 187 private frameSize: Size = {width: 720, height: 1280}; // default for Flicker 188 private frameSizeWorker: number | undefined; 189 190 ngOnChanges(changes: SimpleChanges) { 191 this.changeDetectorRef.detectChanges(); 192 if (this.currentTraceEntries.length === 0) { 193 return; 194 } 195 196 if (!changes['currentTraceEntries']) { 197 return; 198 } 199 200 if (this.safeUrl === undefined) { 201 this.updateSafeUrl(); 202 } 203 } 204 205 ngAfterViewInit() { 206 this.resetFrameSizeWorker(); 207 this.updateMaxContainerSize(); 208 } 209 210 ngOnDestroy() { 211 this.clearFrameSizeWorker(); 212 } 213 214 @HostListener('window:resize', ['$event']) 215 onResize(event: Event) { 216 this.updateMaxContainerSize(); 217 } 218 219 onMinimizeButtonClick() { 220 this.shouldMinimize = !this.shouldMinimize; 221 } 222 223 isMinimized() { 224 return this.forceMinimize || this.shouldMinimize; 225 } 226 227 hasFrameToShow() { 228 const curr = this.currentTraceEntries.at(this.index); 229 return curr && !curr.isImage && curr.videoTimeSeconds !== undefined; 230 } 231 232 hasImage() { 233 return this.currentTraceEntries.at(this.index)?.isImage ?? false; 234 } 235 236 getCurrentTime(): number { 237 return this.currentTraceEntries.at(this.index)?.videoTimeSeconds ?? 0; 238 } 239 240 onSelectChange(event: MatSelectChange) { 241 this.index = event.value; 242 this.updateSafeUrl(); 243 event.source.close(); 244 } 245 246 private resetFrameSizeWorker() { 247 if (this.frameSizeWorker === undefined) { 248 this.frameSizeWorker = window.setInterval( 249 () => this.updateFrameSize(), 250 50, 251 ); 252 } 253 } 254 255 private updateFrameSize() { 256 const video = 257 this.elementRef.nativeElement.querySelector<HTMLVideoElement>('video'); 258 if (video && video.readyState) { 259 this.frameSize = { 260 width: video.videoWidth, 261 height: video.videoHeight, 262 }; 263 this.clearFrameSizeWorker(); 264 this.updateMaxContainerSize(); 265 return; 266 } 267 const image = 268 this.elementRef.nativeElement.querySelector<HTMLImageElement>('img'); 269 if (image) { 270 this.frameSize = { 271 width: image.naturalWidth, 272 height: image.naturalHeight, 273 }; 274 this.clearFrameSizeWorker(); 275 this.updateMaxContainerSize(); 276 } 277 } 278 279 private updateSafeUrl() { 280 const curr = this.currentTraceEntries.at(this.index); 281 if (curr) { 282 this.safeUrl = this.sanitizer.bypassSecurityTrustUrl( 283 URL.createObjectURL(curr.videoData), 284 ); 285 this.changeDetectorRef.detectChanges(); 286 const video = 287 this.elementRef.nativeElement.querySelector<HTMLVideoElement>('video'); 288 if (video) video.currentTime = this.getCurrentTime(); 289 this.resetFrameSizeWorker(); 290 } 291 } 292 293 private updateMaxContainerSize() { 294 this.ngZone.run(() => { 295 const container = assertDefined( 296 this.elementRef.nativeElement.querySelector<HTMLElement>('.container'), 297 ); 298 const maxHeight = window.innerHeight - 140; 299 const headerHeight = 300 this.elementRef.nativeElement.querySelector('.header')?.clientHeight ?? 301 0; 302 const maxWidth = Math.min( 303 ((maxHeight - headerHeight) * this.frameSize.width) / 304 this.frameSize.height, 305 window.innerWidth, 306 ); 307 container.style.maxWidth = `${maxWidth}px`; 308 this.changeDetectorRef.detectChanges(); 309 }); 310 } 311 312 private clearFrameSizeWorker() { 313 window.clearInterval(this.frameSizeWorker); 314 this.frameSizeWorker = undefined; 315 } 316} 317 318export {ViewerMediaBasedComponent}; 319