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