frontend: add recording video player component and integrate into view recording page
This commit is contained in:
parent
5fd737ef0b
commit
9564c7e751
@ -4,6 +4,7 @@ export * from './dialogs/share-recording-dialog/share-recording-dialog.component
|
|||||||
export * from './logo-selector/logo-selector.component';
|
export * from './logo-selector/logo-selector.component';
|
||||||
export * from './pro-feature-badge/pro-feature-badge.component';
|
export * from './pro-feature-badge/pro-feature-badge.component';
|
||||||
export * from './recording-lists/recording-lists.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 './rooms-lists/rooms-lists.component';
|
||||||
export * from './selectable-card/selectable-card.component';
|
export * from './selectable-card/selectable-card.component';
|
||||||
export * from './spinner/spinner.component';
|
export * from './spinner/spinner.component';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,7 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Actions -->
|
<!-- Right: Actions -->
|
||||||
@if (!videoError && recordingUrl) {
|
@if (recordingUrl) {
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
@ -74,7 +74,7 @@
|
|||||||
color="accent"
|
color="accent"
|
||||||
(click)="openShareDialog()"
|
(click)="openShareDialog()"
|
||||||
matTooltip="Share"
|
matTooltip="Share"
|
||||||
class="action-button share-buttosssn"
|
class="action-button share-button"
|
||||||
>
|
>
|
||||||
<mat-icon>share</mat-icon>
|
<mat-icon>share</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
@ -91,58 +91,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Content -->
|
<!-- Video Player Component -->
|
||||||
<div class="mobile-video-content">
|
<ov-recording-video-player
|
||||||
@if (recordingUrl && !videoError) {
|
[recordingUrl]="recordingUrl"
|
||||||
<div class="video-container">
|
[recordingStatus]="recording.status"
|
||||||
<video
|
[showDownload]="false"
|
||||||
[src]="recordingUrl"
|
[showShare]="false"
|
||||||
controls
|
(videoError)="onVideoError()"
|
||||||
controlsList="nodownload nofullscreen"
|
(retry)="retryLoad()"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Desktop/Tablet Experience -->
|
<!-- Desktop/Tablet Experience -->
|
||||||
@ -174,20 +131,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Actions Section -->
|
<!-- Actions Section -->
|
||||||
@if (!videoError) {
|
@if (recordingUrl) {
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<div class="primary-actions">
|
<div class="primary-actions">
|
||||||
@if (recordingUrl) {
|
<button
|
||||||
<button
|
mat-button
|
||||||
mat-button
|
(click)="downloadRecording()"
|
||||||
(click)="downloadRecording()"
|
matTooltip="Download recording"
|
||||||
matTooltip="Download recording"
|
class="action-btn primary-button"
|
||||||
class="action-btn primary-button"
|
>
|
||||||
>
|
<mat-icon>download</mat-icon>
|
||||||
<mat-icon>download</mat-icon>
|
<span>Download</span>
|
||||||
<span>Download</span>
|
</button>
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
@ -206,57 +161,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Player Section -->
|
<!-- Video Player Component -->
|
||||||
<div class="video-section">
|
<ov-recording-video-player
|
||||||
@if (recordingUrl && !videoError) {
|
[recordingUrl]="recordingUrl"
|
||||||
<div class="video-container">
|
[recordingStatus]="recording.status"
|
||||||
<video
|
[showDownload]="false"
|
||||||
[src]="recordingUrl"
|
[showShare]="false"
|
||||||
controls
|
(videoError)="onVideoError()"
|
||||||
controlsList="nodownload"
|
(retry)="retryLoad()"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ===
|
// === MAIN PAGE LAYOUT ===
|
||||||
|
|
||||||
@ -373,94 +286,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === VIDEO SECTION ===
|
// === VIDEO SECTION ===
|
||||||
|
// Video player styles are now in recording-video-player.component.scss
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ACTIONS SECTION ===
|
// === ACTIONS SECTION ===
|
||||||
|
|
||||||
@ -509,15 +335,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include design-tokens.ov-tablet-down {
|
@include design-tokens.ov-tablet-down {
|
||||||
.video-section {
|
// Video player responsive styles moved to recording-video-player.component.scss
|
||||||
.video-error-container,
|
|
||||||
.video-unavailable-container {
|
|
||||||
min-height: 250px;
|
|
||||||
|
|
||||||
.video-error-content,
|
|
||||||
.video-unavailable-content {
|
|
||||||
padding: var(--ov-meet-spacing-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { DatePipe } from '@angular/common';
|
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 { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
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 { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { RecordingVideoPlayerComponent } from '@lib/components';
|
||||||
import { NotificationService, RecordingService } from '@lib/services';
|
import { NotificationService, RecordingService } from '@lib/services';
|
||||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
|
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
|
||||||
import { formatDurationToTime } from '@lib/utils';
|
import { formatDurationToTime } from '@lib/utils';
|
||||||
@ -23,22 +24,17 @@ import { ViewportService } from 'openvidu-components-angular';
|
|||||||
DatePipe,
|
DatePipe,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
MatSnackBarModule
|
MatSnackBarModule,
|
||||||
|
RecordingVideoPlayerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ViewRecordingComponent implements OnInit, OnDestroy {
|
export class ViewRecordingComponent implements OnInit {
|
||||||
recording?: MeetRecordingInfo;
|
recording?: MeetRecordingInfo;
|
||||||
recordingUrl?: string;
|
recordingUrl?: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
|
|
||||||
videoError = false;
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
hasError = false;
|
hasError = false;
|
||||||
isVideoLoaded = false;
|
|
||||||
|
|
||||||
// Mobile UI state
|
|
||||||
showMobileControls = true;
|
|
||||||
private controlsTimeout?: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected recordingService: RecordingService,
|
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() {
|
onVideoError() {
|
||||||
console.error('Error loading video');
|
console.error('Error loading video');
|
||||||
this.videoError = true;
|
this.notificationService.showSnackbar('Error loading video. Please try again.');
|
||||||
this.isVideoLoaded = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadRecording() {
|
downloadRecording() {
|
||||||
if (!this.recording || !this.recordingUrl) {
|
if (this.recording) {
|
||||||
this.notificationService.showSnackbar('Recording is not available for download');
|
this.recordingService.downloadRecording(this.recording, this.secret);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recordingService.downloadRecording(this.recording, this.secret);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openShareDialog() {
|
openShareDialog() {
|
||||||
@ -109,7 +91,6 @@ export class ViewRecordingComponent implements OnInit, OnDestroy {
|
|||||||
async retryLoad() {
|
async retryLoad() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.hasError = false;
|
this.hasError = false;
|
||||||
this.videoError = false;
|
|
||||||
await this.loadRecording();
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user