frontend: enhance view recording component

This commit is contained in:
Carlos Santos 2025-08-04 17:30:06 +02:00
parent 76093b9f42
commit 4d76101d1e
3 changed files with 595 additions and 130 deletions

View File

@ -1,56 +1,161 @@
@if (recording) {
<mat-card class="recording-container">
@if (recordingUrl) {
@if (!videoError) {
<div class="video-container">
<video
autoplay
controls
playsinline
class="video-player"
[src]="recordingUrl"
(error)="videoError = true"
></video>
</div>
} @else {
<div class="video-error">
<mat-icon color="warn">error</mat-icon>
<span>Error loading video. Please try again later.</span>
</div>
}
} @else {
@if (['STARTING', 'ACTIVE', 'ENDING'].includes(recording.status)) {
<div class="status-message">
<mat-icon color="accent">hourglass_empty</mat-icon>
<span>Recording is still in progress...</span>
</div>
} @else {
<div class="status-message error">
<mat-icon color="warn">error_outline</mat-icon>
<span>Recording has failed</span>
</div>
}
}
<!-- Loading State -->
@if (isLoading) {
<div class="ov-page-container">
<div class="loading-container fade-in">
<mat-spinner diameter="48"></mat-spinner>
<p class="loading-text">Loading recording...</p>
</div>
</div>
}
<mat-card-content>
<h2 class="title">{{ recording.roomId }}</h2>
<div class="info-actions">
<span class="date">{{ recording.startDate | date: 'M/d/yy, H:mm' }}</span>
<!-- Error State -->
@else if (hasError) {
<div class="ov-page-container">
<div class="error-container fade-in">
<div class="error-content">
<mat-icon class="error-icon">error_outline</mat-icon>
<h2 class="error-title">Recording Not Found</h2>
<p class="error-message">The recording you're looking for doesn't exist or is no longer available.</p>
<div class="error-actions">
<button mat-raised-button color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
<span>Try Again</span>
</button>
<button mat-button (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
<span>Go Back</span>
</button>
</div>
</div>
</div>
</div>
}
<!-- Recording Content -->
@else if (recording) {
<div class="ov-page-container">
<div class="recording-page-content fade-in">
<!-- Header Section -->
<div class="recording-header">
<div class="header-content">
<div class="header-actions">
<button mat-icon-button (click)="goBack()" matTooltip="Go back" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</button>
</div>
<div class="header-info">
<div class="recording-info">
<h1 class="recording-title">{{ recording.roomId }}</h1>
<div class="recording-metadata">
<span class="recording-date">
<mat-icon class="ov-action-icon">schedule</mat-icon>
{{ recording.startDate | date: 'MMM d, y - h:mm a' }}
</span>
@if (recording.duration) {
<span class="recording-duration">
<mat-icon class="ov-action-icon">timer</mat-icon>
{{ formatDuration(recording.duration) }}
</span>
}
</div>
<div class="recording-status">
<mat-icon [class]="'status-icon ' + recording.status">{{ getStatusIcon() }}</mat-icon>
<span class="status-text">{{ getStatusMessage() }}</span>
</div>
</div>
<!-- Actions Section -->
@if (recordingUrl && !videoError) {
<div class="actions-section">
<div class="primary-actions">
<button
mat-button
(click)="downloadRecording()"
matTooltip="Download recording"
class="action-btn primary-button"
>
<mat-icon>download</mat-icon>
<span>Download</span>
</button>
<button
mat-raised-button
color="accent"
(click)="openShareDialog()"
matTooltip="Share recording"
class="action-btn"
>
<mat-icon>share</mat-icon>
<span>Share</span>
</button>
</div>
</div>
<!-- Share Link Component -->
<!-- <ov-share-meeting-link
[meetingUrl]="recordingUrl"
title="Share this recording"
titleSize="md"
subtitle="Anyone with this link can view the recording"
(copyClicked)="copyRecordingLink()"
></ov-share-meeting-link> -->
}
</div>
</div>
</div>
<!-- Video Player Section -->
<div class="video-section">
@if (recordingUrl && !videoError) {
<div class="actions">
<button mat-icon-button color="primary" (click)="downloadRecording()" aria-label="Download">
<mat-icon>download</mat-icon>
</button>
<button mat-icon-button color="accent" (click)="openShareDialog()" aria-label="Share">
<mat-icon>share</mat-icon>
</button>
<div class="video-container">
<video
#videoPlayer
controls
controlsList="nodownload"
playsinline
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
>
<source [src]="recordingUrl" type="video/mp4" />
</video>
@if (!isVideoLoaded) {
<div class="video-loading-overlay">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading video...</span>
</div>
}
</div>
} @else if (videoError) {
<div class="video-error-container">
<div class="video-error-content">
<mat-icon class="error-icon">error</mat-icon>
<h3>Video Playback Error</h3>
<p>There was an error loading the video. Please check your connection and try again.</p>
<button mat-raised-button color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
</button>
</div>
</div>
} @else {
<div class="video-unavailable-container">
<div class="video-unavailable-content">
<mat-icon class="status-icon">{{ getStatusIcon() }}</mat-icon>
<h3>{{ getStatusMessage() }}</h3>
@if (['STARTING', 'ACTIVE', 'ENDING'].includes(recording.status)) {
<p>The recording is still being processed. Please check back in a few minutes.</p>
<button mat-raised-button color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
<span>Refresh</span>
</button>
} @else {
<p>This recording is not available for playback.</p>
}
</div>
</div>
}
</div>
</mat-card-content>
</mat-card>
} @else {
<div class="loader-container">
<mat-spinner></mat-spinner>
</div>
</div>
}

View File

@ -1,82 +1,337 @@
.loader-container {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
@import '../../../../../../src/assets/styles/design-tokens';
// === MAIN PAGE LAYOUT ===
.recording-page-content {
@include ov-container;
@include ov-page-content;
max-width: 1000px;
padding-top: var(--ov-meet-spacing-xl);
}
.recording-container {
max-width: 900px;
margin: 20px auto;
padding: 0;
}
// === LOADING STATES ===
.video-container {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 aspect ratio */
}
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.status-message {
display: flex;
align-items: center;
gap: 8px;
color: #555;
font-size: 1.1rem;
padding: 20px;
}
.status-message.error {
color: #d32f2f;
}
.video-error {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
background-color: #fbe9e7;
border: 1px solid #ef9a9a;
color: #c62828;
border-radius: 4px;
margin-bottom: 10px;
}
.title {
font-size: 1.8rem;
font-weight: 600;
margin: 16px 0 8px;
}
.info-actions {
display: flex;
.loading-container {
@include ov-flex-center;
flex-direction: column;
gap: 8px;
}
gap: var(--ov-meet-spacing-md);
height: 60vh;
color: var(--ov-meet-text-secondary);
@media (min-width: 600px) {
.info-actions {
flex-direction: row;
justify-content: space-between;
align-items: center;
.loading-text {
margin: 0;
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-medium);
}
}
.date {
color: #666;
font-size: 0.95rem;
// === ERROR STATES ===
.error-container {
@include ov-flex-center;
min-height: 60vh;
padding: var(--ov-meet-spacing-xl);
.error-content {
text-align: center;
max-width: 500px;
.error-icon {
@include ov-icon(xl);
color: var(--ov-meet-color-error);
margin-bottom: var(--ov-meet-spacing-md);
}
.error-title {
margin: 0 0 var(--ov-meet-spacing-sm) 0;
font-size: var(--ov-meet-font-size-xxl);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
}
.error-message {
margin: 0 0 var(--ov-meet-spacing-lg) 0;
font-size: var(--ov-meet-font-size-md);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-relaxed);
}
.error-actions {
display: flex;
gap: var(--ov-meet-spacing-md);
justify-content: center;
flex-wrap: wrap;
button {
@include ov-button-base;
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
// === HEADER SECTION ===
.recording-header {
margin-bottom: var(--ov-meet-spacing-xl);
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--ov-meet-spacing-lg);
@include ov-tablet-down {
flex-direction: column;
align-items: stretch;
}
}
.header-info {
display: flex;
flex: 1;
.recording-info {
width: 50%;
}
.recording-title {
margin: 0 0 var(--ov-meet-spacing-sm) 0;
font-size: var(--ov-meet-font-size-hero);
font-weight: var(--ov-meet-font-weight-light);
color: var(--ov-meet-text-primary);
line-height: var(--ov-meet-line-height-tight);
@include ov-mobile-down {
font-size: var(--ov-meet-font-size-xxl);
}
}
.recording-metadata {
display: flex;
flex-wrap: wrap;
gap: var(--ov-meet-spacing-lg);
margin-bottom: var(--ov-meet-spacing-sm);
@include ov-mobile-down {
flex-direction: column;
gap: var(--ov-meet-spacing-xs);
}
.recording-date,
.recording-duration {
@include ov-flex-center;
gap: var(--ov-meet-spacing-xs);
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
mat-icon {
@include ov-icon(sm);
}
}
}
.recording-status {
@include ov-flex-center;
gap: var(--ov-meet-spacing-xs);
justify-content: flex-start;
.status-icon {
@include ov-icon(sm);
&.COMPLETE {
color: var(--ov-meet-color-success);
}
&.STARTING,
&.ACTIVE,
&.ENDING {
color: var(--ov-meet-color-warning);
}
&.FAILED {
color: var(--ov-meet-color-error);
}
}
.status-text {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
font-weight: var(--ov-meet-font-weight-medium);
}
}
}
.header-actions {
display: flex;
gap: var(--ov-meet-spacing-sm);
@include ov-tablet-down {
justify-content: flex-end;
}
.back-btn {
@include ov-button-base;
@include ov-theme-transition;
color: var(--ov-meet-text-secondary);
padding: 0;
&:hover {
color: var(--ov-meet-text-primary);
background-color: var(--ov-meet-surface-hover);
}
}
}
}
// === VIDEO SECTION ===
.video-section {
margin-bottom: var(--ov-meet-spacing-xl);
.video-container {
position: relative;
width: 100%;
padding-top: 56.25%; // 16:9 aspect ratio
background: var(--ov-meet-surface-color);
border-radius: var(--ov-meet-radius-lg);
overflow: hidden;
box-shadow: var(--ov-meet-shadow-md);
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--ov-meet-background-color);
}
.video-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include ov-flex-center;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
background: var(--ov-meet-surface-color);
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
}
}
.video-error-container,
.video-unavailable-container {
@include ov-flex-center;
min-height: 300px;
background: var(--ov-meet-surface-color);
border-radius: var(--ov-meet-radius-lg);
border: 1px solid var(--ov-meet-border-color-light);
.video-error-content,
.video-unavailable-content {
text-align: center;
max-width: 400px;
padding: var(--ov-meet-spacing-xl);
.error-icon,
.status-icon {
@include ov-icon(xl);
margin-bottom: var(--ov-meet-spacing-md);
}
.error-icon {
color: var(--ov-meet-color-error);
}
.status-icon {
color: var(--ov-meet-text-secondary);
}
h3 {
margin: 0 0 var(--ov-meet-spacing-sm) 0;
font-size: var(--ov-meet-font-size-xl);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
}
p {
margin: 0 0 var(--ov-meet-spacing-lg) 0;
font-size: var(--ov-meet-font-size-md);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-relaxed);
}
button {
@include ov-button-base;
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
}
// === ACTIONS SECTION ===
.actions-section {
flex: 2;
align-self: self-end;
.primary-actions {
@include ov-flex-center;
gap: var(--ov-meet-spacing-md);
flex-wrap: wrap;
justify-content: end;
.action-btn {
@include ov-button-base;
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
min-width: 120px;
height: 48px;
mat-icon {
@include ov-icon(sm);
}
}
}
}
// === RESPONSIVE ADJUSTMENTS ===
@include ov-mobile-down {
.recording-page-content {
padding: var(--ov-meet-spacing-lg);
padding-top: var(--ov-meet-spacing-xl);
}
.actions-section .primary-actions {
flex-direction: column;
align-items: stretch;
.action-btn {
width: 100%;
justify-content: center;
}
}
}
@include ov-tablet-down {
.video-section {
.video-error-container,
.video-unavailable-container {
min-height: 250px;
.video-error-content,
.video-unavailable-content {
padding: var(--ov-meet-spacing-lg);
}
}
}
}

View File

@ -1,59 +1,164 @@
import { DatePipe } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { RecordingManagerService } from '@lib/services';
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
import { ActionService } from 'openvidu-components-angular';
import { ShareMeetingLinkComponent } from '@lib/components/share-meeting-link/share-meeting-link.component';
@Component({
selector: 'app-view-recording',
selector: 'ov-view-recording',
templateUrl: './view-recording.component.html',
styleUrls: ['./view-recording.component.scss'],
standalone: true,
imports: [MatCardModule, MatButtonModule, MatIconModule, DatePipe, MatProgressSpinnerModule]
imports: [
MatCardModule,
MatButtonModule,
MatIconModule,
DatePipe,
MatProgressSpinnerModule,
MatTooltipModule,
MatSnackBarModule,
ShareMeetingLinkComponent
]
})
export class ViewRecordingComponent implements OnInit {
export class ViewRecordingComponent implements OnInit, OnDestroy {
recording?: MeetRecordingInfo;
recordingUrl?: string;
videoError = false;
isLoading = true;
hasError = false;
isVideoLoaded = false;
constructor(
protected recordingService: RecordingManagerService,
protected actionService: ActionService,
protected route: ActivatedRoute
protected route: ActivatedRoute,
protected router: Router,
private snackBar: MatSnackBar
) {}
async ngOnInit() {
await this.loadRecording();
}
ngOnDestroy() {}
private async loadRecording() {
const recordingId = this.route.snapshot.paramMap.get('recording-id');
const secret = this.route.snapshot.queryParams['secret'];
if (!recordingId) {
this.hasError = true;
this.isLoading = false;
return;
}
try {
this.recording = await this.recordingService.getRecording(recordingId!, secret);
this.recording = await this.recordingService.getRecording(recordingId, secret);
if (this.recording.status === MeetRecordingStatus.COMPLETE) {
this.recordingUrl = this.recordingService.getRecordingMediaUrl(recordingId!, secret);
this.recordingUrl = this.recordingService.getRecordingMediaUrl(recordingId, secret);
}
console.warn('Recording loaded:', this.recordingUrl);
} catch (error) {
console.error('Error fetching recording:', error);
this.hasError = true;
} finally {
this.isLoading = false;
}
}
onVideoLoaded = () => {
this.isVideoLoaded = true;
this.videoError = false;
};
onVideoError = () => {
this.videoError = true;
this.isVideoLoaded = false;
};
downloadRecording() {
if (!this.recording || !this.recordingUrl) {
console.error('Recording is not available for download');
this.snackBar.open('Recording is not available for download', 'Close', { duration: 3000 });
return;
}
this.recordingService.downloadRecording(this.recording);
this.snackBar.open('Download started', 'Close', { duration: 2000 });
}
openShareDialog() {
const url = window.location.href;
this.recordingService.openShareRecordingDialog(this.recording!.recordingId, url);
}
// copyRecordingLink() {
// const url = window.location.href;
// navigator.clipboard.writeText(url).then(() => {
// this.snackBar.open('Link copied to clipboard', 'Close', { duration: 2000 });
// }).catch(() => {
// this.snackBar.open('Failed to copy link', 'Close', { duration: 3000 });
// });
// }
goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
this.router.navigate(['/']);
}
}
retryLoad() {
this.isLoading = true;
this.hasError = false;
this.videoError = false;
this.loadRecording();
}
getStatusIcon(): string {
if (!this.recording) return 'error_outline';
switch (this.recording.status) {
case MeetRecordingStatus.STARTING:
case MeetRecordingStatus.ACTIVE:
case MeetRecordingStatus.ENDING:
return 'hourglass_empty';
case MeetRecordingStatus.COMPLETE:
return 'check_circle';
default:
return 'error_outline';
}
}
getStatusMessage(): string {
if (!this.recording) return 'Recording not found';
switch (this.recording.status) {
case MeetRecordingStatus.STARTING:
return 'Recording is starting...';
case MeetRecordingStatus.ACTIVE:
return 'Recording is in progress...';
case MeetRecordingStatus.ENDING:
return 'Recording is finalizing...';
case MeetRecordingStatus.COMPLETE:
return 'Recording is ready to watch';
default:
return 'Recording has failed';
}
}
formatDuration(durationSeconds: number): string {
const minutes = Math.floor(durationSeconds / 60);
const seconds = Math.floor(durationSeconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}