frontend: add recording video player component and integrate into view recording page

This commit is contained in:
Carlos Santos 2025-10-06 16:25:12 +02:00
parent 5fd737ef0b
commit 9564c7e751
8 changed files with 438 additions and 348 deletions

View File

@ -4,6 +4,7 @@ export * from './dialogs/share-recording-dialog/share-recording-dialog.component
export * from './logo-selector/logo-selector.component';
export * from './pro-feature-badge/pro-feature-badge.component';
export * from './recording-lists/recording-lists.component';
export * from './recording-video-player/recording-video-player.component';
export * from './rooms-lists/rooms-lists.component';
export * from './selectable-card/selectable-card.component';
export * from './spinner/spinner.component';

View File

@ -0,0 +1,60 @@
<div class="video-player-wrapper" [class.mobile]="viewportService.isMobileView()">
<!-- Video Player -->
@if (recordingUrl && !hasVideoError) {
<div class="video-container" [class.mobile]="viewportService.isMobileView()">
<video
#videoPlayer
[src]="recordingUrl"
controls
controlsList="nodownload"
playsinline
autoplay
[muted]="viewportService.isMobileView()"
[disablePictureInPicture]="viewportService.isMobileView()"
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
></video>
@if (!isVideoLoaded) {
<div class="video-loading-overlay">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading video...</span>
</div>
}
</div>
}
<!-- Video Error State -->
@else if (hasVideoError) {
<div class="video-error-container">
<div class="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)="onRetryClick()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
</button>
</div>
</div>
}
<!-- Video Unavailable State -->
@else {
<div class="video-unavailable-container">
<div class="unavailable-content">
<mat-icon class="status-icon">{{ getStatusIcon() }}</mat-icon>
<h3>{{ getStatusMessage() }}</h3>
@if (isRecordingInProgress()) {
<p>The recording is still being processed. Please check back in a few minutes.</p>
<button mat-raised-button color="primary" (click)="onRetryClick()">
<mat-icon>refresh</mat-icon>
<span>Refresh</span>
</button>
} @else {
<p>This recording is not available for playback.</p>
}
</div>
</div>
}
</div>

View File

@ -0,0 +1,159 @@
@use '../../../../../../src/assets/styles/design-tokens';
// === VIDEO PLAYER WRAPPER ===
.video-player-wrapper {
// Default: Desktop/Tablet styles
margin-bottom: var(--ov-meet-spacing-xl);
// Mobile styles
&.mobile {
background: var(--ov-meet-background-color);
min-height: inherit;
margin-bottom: 0;
}
}
// === VIDEO CONTAINER ===
.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;
&.mobile {
position: unset;
padding-top: 0;
}
// Desktop/Tablet: add shadow
.video-player-wrapper:not(.mobile) & {
box-shadow: var(--ov-meet-shadow-md);
}
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: var(--ov-meet-background-color);
}
.video-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include design-tokens.ov-flex-center;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
background: var(--ov-meet-background-color);
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
}
}
// === ERROR & UNAVAILABLE STATES ===
.video-error-container,
.video-unavailable-container {
@include design-tokens.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);
// Mobile: add bottom margin
.video-player-wrapper.mobile & {
margin-bottom: var(--ov-meet-spacing-lg);
}
.error-content,
.unavailable-content {
text-align: center;
padding: var(--ov-meet-spacing-xl);
// Desktop: larger max-width
.video-player-wrapper:not(.mobile) & {
max-width: 400px;
}
// Mobile: smaller max-width
.video-player-wrapper.mobile & {
max-width: 300px;
}
.error-icon,
.status-icon {
@include design-tokens.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-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
// Desktop: larger font
.video-player-wrapper:not(.mobile) & {
font-size: var(--ov-meet-font-size-xl);
}
// Mobile: smaller font
.video-player-wrapper.mobile & {
font-size: var(--ov-meet-font-size-lg);
}
}
p {
margin: 0 0 var(--ov-meet-spacing-lg) 0;
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-relaxed);
// Desktop: larger font
.video-player-wrapper:not(.mobile) & {
font-size: var(--ov-meet-font-size-md);
}
// Mobile: smaller font
.video-player-wrapper.mobile & {
font-size: var(--ov-meet-font-size-sm);
}
}
button {
@include design-tokens.ov-button-base;
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
// === RESPONSIVE ADJUSTMENTS ===
@include design-tokens.ov-tablet-down {
.video-error-container,
.video-unavailable-container {
min-height: 250px;
.error-content,
.unavailable-content {
padding: var(--ov-meet-spacing-lg);
}
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecordingVideoPlayerComponent } from './recording-video-player.component';
describe('RecordingVideoPlayerComponent', () => {
let component: RecordingVideoPlayerComponent;
let fixture: ComponentFixture<RecordingVideoPlayerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RecordingVideoPlayerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RecordingVideoPlayerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,155 @@
import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRecordingStatus } from '@lib/typings/ce';
import { ViewportService } from 'openvidu-components-angular';
@Component({
selector: 'ov-recording-video-player',
imports: [MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './recording-video-player.component.html',
styleUrl: './recording-video-player.component.scss'
})
export class RecordingVideoPlayerComponent implements OnDestroy {
/**
* URL of the recording video to play
*/
@Input() recordingUrl?: string;
/**
* Recording status (to show appropriate messages)
*/
@Input() recordingStatus: MeetRecordingStatus = MeetRecordingStatus.COMPLETE;
/**
* Whether to show download button
*/
@Input() showDownload = true;
/**
* Whether to show share button
*/
@Input() showShare = true;
/**
* Emitted when video successfully loads
*/
@Output() videoLoaded = new EventEmitter<void>();
/**
* Emitted when video fails to load
*/
@Output() videoError = new EventEmitter<void>();
/**
* Emitted when download button is clicked
*/
@Output() download = new EventEmitter<void>();
/**
* Emitted when share button is clicked
*/
@Output() share = new EventEmitter<void>();
/**
* Emitted when retry button is clicked
*/
@Output() retry = new EventEmitter<void>();
// Internal state
hasVideoError = false;
isVideoLoaded = false;
showMobileControls = true;
@ViewChild('videoPlayer', { static: false }) videoPlayer?: ElementRef<HTMLVideoElement>;
private controlsTimeout?: number;
constructor(public viewportService: ViewportService) {}
onVideoLoaded() {
this.isVideoLoaded = true;
this.hasVideoError = false;
this.videoLoaded.emit();
// Start controls timeout for mobile
if (this.viewportService.isMobileView()) {
this.resetControlsTimeout();
}
}
onVideoError() {
console.error('Error loading video');
this.hasVideoError = true;
this.isVideoLoaded = false;
this.videoError.emit();
}
onDownloadClick() {
this.download.emit();
}
onShareClick() {
this.share.emit();
}
onRetryClick() {
this.hasVideoError = false;
this.isVideoLoaded = false;
this.retry.emit();
}
getStatusIcon(): string {
switch (this.recordingStatus) {
case MeetRecordingStatus.STARTING:
case MeetRecordingStatus.ACTIVE:
case MeetRecordingStatus.ENDING:
return 'hourglass_empty';
case MeetRecordingStatus.COMPLETE:
return 'check_circle';
default:
return 'error_outline';
}
}
getStatusMessage(): string {
switch (this.recordingStatus) {
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';
}
}
isRecordingInProgress(): boolean {
return [MeetRecordingStatus.STARTING, MeetRecordingStatus.ACTIVE, MeetRecordingStatus.ENDING].includes(
this.recordingStatus
);
}
private resetControlsTimeout(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
if (this.showMobileControls) {
this.controlsTimeout = window.setTimeout(() => {
this.showMobileControls = false;
}, 3000); // Hide controls after 3 seconds
}
}
ngOnDestroy(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
}
}

View File

@ -58,7 +58,7 @@
</div>
<!-- Right: Actions -->
@if (!videoError && recordingUrl) {
@if (recordingUrl) {
<div class="header-actions">
<button
mat-icon-button
@ -74,7 +74,7 @@
color="accent"
(click)="openShareDialog()"
matTooltip="Share"
class="action-button share-buttosssn"
class="action-button share-button"
>
<mat-icon>share</mat-icon>
</button>
@ -91,58 +91,15 @@
}
</div>
<!-- Video Content -->
<div class="mobile-video-content">
@if (recordingUrl && !videoError) {
<div class="video-container">
<video
[src]="recordingUrl"
controls
controlsList="nodownload nofullscreen"
playsinline
disablePictureInPicture
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
></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="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="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 playbook.</p>
}
</div>
</div>
}
</div>
<!-- Video Player Component -->
<ov-recording-video-player
[recordingUrl]="recordingUrl"
[recordingStatus]="recording.status"
[showDownload]="false"
[showShare]="false"
(videoError)="onVideoError()"
(retry)="retryLoad()"
/>
</div>
} @else {
<!-- Desktop/Tablet Experience -->
@ -174,20 +131,18 @@
</div>
</div>
<!-- Actions Section -->
@if (!videoError) {
@if (recordingUrl) {
<div class="actions-section">
<div class="primary-actions">
@if (recordingUrl) {
<button
mat-button
(click)="downloadRecording()"
matTooltip="Download recording"
class="action-btn primary-button"
>
<mat-icon>download</mat-icon>
<span>Download</span>
</button>
}
<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
@ -206,57 +161,15 @@
</div>
</div>
<!-- Video Player Section -->
<div class="video-section">
@if (recordingUrl && !videoError) {
<div class="video-container">
<video
[src]="recordingUrl"
controls
controlsList="nodownload"
playsinline
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
></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>
<!-- Video Player Component -->
<ov-recording-video-player
[recordingUrl]="recordingUrl"
[recordingStatus]="recording.status"
[showDownload]="false"
[showShare]="false"
(videoError)="onVideoError()"
(retry)="retryLoad()"
/>
</div>
</div>
}

View File

@ -119,93 +119,6 @@
}
}
// === MOBILE VIDEO CONTENT ===
.mobile-video-content {
background: var(--ov-meet-background-color);
min-height: inherit;
}
.video-container {
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: var(--ov-meet-background-color);
}
.video-player::-webkit-media-controls-fullscreen-button {
display: none;
}
.video-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include design-tokens.ov-flex-center;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
background: var(--ov-meet-background-color);
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
}
}
.video-error-container,
.video-unavailable-container {
@include design-tokens.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);
margin-bottom: var(--ov-meet-spacing-lg);
.error-content,
.unavailable-content {
text-align: center;
max-width: 300px;
padding: var(--ov-meet-spacing-xl);
.error-icon,
.status-icon {
@include design-tokens.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-lg);
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-sm);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-relaxed);
}
button {
@include design-tokens.ov-button-base;
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
// === MAIN PAGE LAYOUT ===
@ -373,94 +286,7 @@
}
// === 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 design-tokens.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 design-tokens.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 design-tokens.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 design-tokens.ov-button-base;
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
}
// Video player styles are now in recording-video-player.component.scss
// === ACTIONS SECTION ===
@ -509,15 +335,5 @@
}
@include design-tokens.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);
}
}
}
// Video player responsive styles moved to recording-video-player.component.scss
}

View File

@ -1,5 +1,5 @@
import { DatePipe } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -7,6 +7,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { RecordingVideoPlayerComponent } from '@lib/components';
import { NotificationService, RecordingService } from '@lib/services';
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
import { formatDurationToTime } from '@lib/utils';
@ -23,22 +24,17 @@ import { ViewportService } from 'openvidu-components-angular';
DatePipe,
MatProgressSpinnerModule,
MatTooltipModule,
MatSnackBarModule
MatSnackBarModule,
RecordingVideoPlayerComponent
]
})
export class ViewRecordingComponent implements OnInit, OnDestroy {
export class ViewRecordingComponent implements OnInit {
recording?: MeetRecordingInfo;
recordingUrl?: string;
secret?: string;
videoError = false;
isLoading = true;
hasError = false;
isVideoLoaded = false;
// Mobile UI state
showMobileControls = true;
private controlsTimeout?: number;
constructor(
protected recordingService: RecordingService,
@ -76,29 +72,15 @@ export class ViewRecordingComponent implements OnInit, OnDestroy {
}
}
onVideoLoaded() {
this.isVideoLoaded = true;
this.videoError = false;
// Start controls timeout for mobile
if (this.viewportService.isMobileView()) {
this.resetControlsTimeout();
}
}
onVideoError() {
console.error('Error loading video');
this.videoError = true;
this.isVideoLoaded = false;
this.notificationService.showSnackbar('Error loading video. Please try again.');
}
downloadRecording() {
if (!this.recording || !this.recordingUrl) {
this.notificationService.showSnackbar('Recording is not available for download');
return;
if (this.recording) {
this.recordingService.downloadRecording(this.recording, this.secret);
}
this.recordingService.downloadRecording(this.recording, this.secret);
}
openShareDialog() {
@ -109,7 +91,6 @@ export class ViewRecordingComponent implements OnInit, OnDestroy {
async retryLoad() {
this.isLoading = true;
this.hasError = false;
this.videoError = false;
await this.loadRecording();
}
@ -158,23 +139,5 @@ export class ViewRecordingComponent implements OnInit, OnDestroy {
}
}
// Mobile UI interactions
private resetControlsTimeout(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
if (this.showMobileControls) {
this.controlsTimeout = window.setTimeout(() => {
this.showMobileControls = false;
}, 3000); // Hide controls after 3 seconds
}
}
ngOnDestroy(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
}
}