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