From 9564c7e751a5dcbf34cd64def84849d91fe0672e Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 6 Oct 2025 16:25:12 +0200 Subject: [PATCH] frontend: add recording video player component and integrate into view recording page --- .../src/lib/components/index.ts | 1 + .../recording-video-player.component.html | 60 ++++++ .../recording-video-player.component.scss | 159 +++++++++++++++ .../recording-video-player.component.spec.ts | 23 +++ .../recording-video-player.component.ts | 155 +++++++++++++++ .../view-recording.component.html | 147 +++----------- .../view-recording.component.scss | 188 +----------------- .../view-recording.component.ts | 53 +---- 8 files changed, 438 insertions(+), 348 deletions(-) create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.ts diff --git a/frontend/projects/shared-meet-components/src/lib/components/index.ts b/frontend/projects/shared-meet-components/src/lib/components/index.ts index 1ab6255..b5ceb4b 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/index.ts @@ -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'; diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.html b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.html new file mode 100644 index 0000000..6967d64 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.html @@ -0,0 +1,60 @@ +
+ + @if (recordingUrl && !hasVideoError) { +
+ + + @if (!isVideoLoaded) { +
+ + Loading video... +
+ } +
+ } + + @else if (hasVideoError) { +
+
+ error +

Video Playback Error

+

There was an error loading the video. Please check your connection and try again.

+ +
+
+ } + + @else { +
+
+ {{ getStatusIcon() }} +

{{ getStatusMessage() }}

+ @if (isRecordingInProgress()) { +

The recording is still being processed. Please check back in a few minutes.

+ + } @else { +

This recording is not available for playback.

+ } +
+
+ } +
+ diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.scss b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.scss new file mode 100644 index 0000000..8128df6 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.scss @@ -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); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.spec.ts new file mode 100644 index 0000000..4cb2dab --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecordingVideoPlayerComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RecordingVideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.ts b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.ts new file mode 100644 index 0000000..3dd73fa --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-video-player/recording-video-player.component.ts @@ -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(); + + /** + * Emitted when video fails to load + */ + @Output() videoError = new EventEmitter(); + + /** + * Emitted when download button is clicked + */ + @Output() download = new EventEmitter(); + + /** + * Emitted when share button is clicked + */ + @Output() share = new EventEmitter(); + + /** + * Emitted when retry button is clicked + */ + @Output() retry = new EventEmitter(); + + // Internal state + hasVideoError = false; + isVideoLoaded = false; + showMobileControls = true; + + @ViewChild('videoPlayer', { static: false }) videoPlayer?: ElementRef; + + 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); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.html b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.html index bc7c657..92bf130 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.html @@ -58,7 +58,7 @@ - @if (!videoError && recordingUrl) { + @if (recordingUrl) {
@@ -91,58 +91,15 @@ }
- -
- @if (recordingUrl && !videoError) { -
- - - @if (!isVideoLoaded) { -
- - Loading video... -
- } -
- } @else if (videoError) { -
-
- error -

Video Playback Error

-

There was an error loading the video. Please check your connection and try again.

- -
-
- } @else { -
-
- {{ getStatusIcon() }} -

{{ getStatusMessage() }}

- @if (['starting', 'active', 'ending'].includes(recording.status)) { -

The recording is still being processed. Please check back in a few minutes.

- - } @else { -

This recording is not available for playbook.

- } -
-
- } -
+ + } @else { @@ -174,20 +131,18 @@ - @if (!videoError) { + @if (recordingUrl) {
- @if (recordingUrl) { - - } +
- -
- @if (recordingUrl && !videoError) { -
- - - @if (!isVideoLoaded) { -
- - Loading video... -
- } -
- } @else if (videoError) { -
-
- error -

Video Playback Error

-

There was an error loading the video. Please check your connection and try again.

- -
-
- } @else { -
-
- {{ getStatusIcon() }} -

{{ getStatusMessage() }}

- @if (['starting', 'active', 'ending'].includes(recording.status)) { -

The recording is still being processed. Please check back in a few minutes.

- - } @else { -

This recording is not available for playback.

- } -
-
- } -
+ +
} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.scss index 355d98e..4dda354 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.scss @@ -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 } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.ts index caa963d..3458ded 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/view-recording/view-recording.component.ts @@ -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); - } - } }