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 Component, 18 ElementRef, 19 HostListener, 20 Inject, 21 Input, 22 SimpleChanges, 23} from '@angular/core'; 24import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; 25import {ScreenRecordingTraceEntry} from 'trace/screen_recording'; 26 27@Component({ 28 selector: 'viewer-screen-recording', 29 template: ` 30 <div class="overlay"> 31 <mat-card class="container" cdkDrag cdkDragBoundary=".overlay"> 32 <mat-card-title class="header"> 33 <button mat-button class="button-drag" cdkDragHandle> 34 <mat-icon class="drag-icon">drag_indicator</mat-icon> 35 <span class="mat-body-2">{{ title }}</span> 36 </button> 37 38 <button mat-button class="button-minimize" [disabled]="forceMinimize" (click)="onMinimizeButtonClick()"> 39 <mat-icon> 40 {{ isMinimized() ? 'maximize' : 'minimize' }} 41 </mat-icon> 42 </button> 43 </mat-card-title> 44 <div class="video-container" cdkDragHandle [style.height]="isMinimized() ? '0px' : ''"> 45 <ng-container *ngIf="hasFrameToShow(); then video; else noVideo"> </ng-container> 46 </div> 47 </mat-card> 48 49 <ng-template #video> 50 <video 51 *ngIf="hasFrameToShow()" 52 [class.ready]="videoElement.readyState" 53 [currentTime]="currentTraceEntry.videoTimeSeconds" 54 [src]="safeUrl" 55 #videoElement></video> 56 </ng-template> 57 58 <ng-template #noVideo> 59 <img *ngIf="hasImage()" [src]="safeUrl" /> 60 61 <div class="no-video" *ngIf="!hasImage()"> 62 <p class="mat-body-2">No screen recording frame to show.</p> 63 <p class="mat-body-1">Current timestamp is still before first frame.</p> 64 </div> 65 </ng-template> 66 </div> 67 `, 68 styles: [ 69 ` 70 .overlay { 71 z-index: 30; 72 position: fixed; 73 top: 0px; 74 left: 0px; 75 width: 100%; 76 height: 100%; 77 pointer-events: none; 78 } 79 80 .container { 81 pointer-events: all; 82 width: max(250px, 15vw); 83 min-width: 165px; 84 resize: horizontal; 85 overflow: hidden; 86 display: flex; 87 flex-direction: column; 88 padding: 0; 89 left: 80vw; 90 top: 20vh; 91 } 92 93 .header { 94 display: flex; 95 flex-direction: row; 96 margin: 0px; 97 border: 1px solid var(--border-color); 98 border-radius: 4px; 99 } 100 101 .button-drag { 102 flex-grow: 1; 103 cursor: grab; 104 padding: 2px; 105 } 106 107 .drag-icon { 108 float: left; 109 margin: 5px 0; 110 } 111 112 .button-minimize { 113 flex-grow: 0; 114 padding: 2px; 115 min-width: 24px; 116 } 117 118 .video-container, video, img { 119 border: 1px solid var(--default-border); 120 width: 100%; 121 height: auto; 122 cursor: grab; 123 overflow: hidden; 124 } 125 126 .no-video { 127 padding: 1rem; 128 text-align: center; 129 } 130 `, 131 ], 132}) 133class ViewerScreenRecordingComponent { 134 safeUrl: undefined | SafeUrl = undefined; 135 shouldMinimize = false; 136 137 constructor( 138 @Inject(DomSanitizer) private sanitizer: DomSanitizer, 139 @Inject(ElementRef) private elementRef: ElementRef, 140 ) {} 141 142 @Input() currentTraceEntry: ScreenRecordingTraceEntry | undefined; 143 @Input() title = 'Screen recording'; 144 @Input() forceMinimize = false; 145 146 private frameHeight = 1280; // default for Flicker/Winscope 147 private frameWidth = 720; // default for Flicker/Winscope 148 149 private videoObserver: MutationObserver | undefined; 150 151 ngOnChanges(changes: SimpleChanges) { 152 if (this.currentTraceEntry === undefined) { 153 return; 154 } 155 156 if (!changes['currentTraceEntry']) { 157 return; 158 } 159 160 if (this.safeUrl === undefined) { 161 this.safeUrl = this.sanitizer.bypassSecurityTrustUrl( 162 URL.createObjectURL(this.currentTraceEntry.videoData), 163 ); 164 } 165 } 166 167 async ngAfterViewInit() { 168 const video: HTMLVideoElement | null = 169 this.elementRef.nativeElement.querySelector('video'); 170 171 if (video) { 172 const config = { 173 attributes: true, 174 CharacterData: true, 175 }; 176 177 const videoCallback = ( 178 mutations: MutationRecord[], 179 observer: MutationObserver, 180 ) => { 181 for (const mutation of mutations) { 182 if ( 183 mutation.type === 'attributes' && 184 mutation.attributeName === 'class' 185 ) { 186 if (video?.className.includes('ready')) { 187 this.frameHeight = video?.videoHeight; 188 this.frameWidth = video?.videoWidth; 189 observer.disconnect(); 190 } 191 } 192 } 193 }; 194 195 this.videoObserver = new MutationObserver(videoCallback); 196 this.videoObserver.observe(video, config); 197 } else { 198 const image: HTMLImageElement | null = 199 this.elementRef.nativeElement.querySelector('img'); 200 if (image) { 201 this.frameHeight = image.naturalHeight; 202 this.frameWidth = image.naturalWidth; 203 } 204 } 205 206 this.updateMaxContainerSize(); 207 } 208 209 ngOnDestroy() { 210 this.videoObserver?.disconnect(); 211 } 212 213 @HostListener('window:resize', ['$event']) 214 onResize(event: Event) { 215 this.updateMaxContainerSize(); 216 } 217 218 updateMaxContainerSize() { 219 const container = this.elementRef.nativeElement.querySelector('.container'); 220 const maxHeight = window.innerHeight - 140; 221 const headerHeight = 222 this.elementRef.nativeElement.querySelector('.header').clientHeight; 223 const maxWidth = 224 ((maxHeight - headerHeight) * this.frameWidth) / this.frameHeight; 225 container.style.maxWidth = `${maxWidth}px`; 226 } 227 228 onMinimizeButtonClick() { 229 this.shouldMinimize = !this.shouldMinimize; 230 } 231 232 isMinimized() { 233 return this.forceMinimize || this.shouldMinimize; 234 } 235 236 hasFrameToShow() { 237 return ( 238 !this.currentTraceEntry?.isImage && 239 this.currentTraceEntry?.videoTimeSeconds !== undefined 240 ); 241 } 242 243 hasImage() { 244 return this.currentTraceEntry?.isImage ?? false; 245 } 246} 247 248export {ViewerScreenRecordingComponent}; 249