frontend: Implement recording detail page with loading and error states
This commit is contained in:
parent
333bd0e92f
commit
b9550aced9
@ -176,11 +176,19 @@
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
|
||||
<mat-icon class="status-icon" [style.color]="getStatusColor(recording.status)">
|
||||
{{ getStatusIcon(recording.status) }}
|
||||
<div
|
||||
class="status-badge"
|
||||
[style.color]="RecordingUiUtils.getStatusColor(recording.status)"
|
||||
>
|
||||
<mat-icon
|
||||
class="status-icon"
|
||||
[style.color]="RecordingUiUtils.getStatusColor(recording.status)"
|
||||
>
|
||||
{{ RecordingUiUtils.getStatusIcon(recording.status) }}
|
||||
</mat-icon>
|
||||
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
||||
<span class="status-label">{{
|
||||
RecordingUiUtils.getStatusLabel(recording.status)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,7 +220,9 @@
|
||||
<mat-icon class="detail-icon">timer</mat-icon>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Duration</div>
|
||||
<div class="detail-value">{{ formatDuration(recording.duration) }}</div>
|
||||
<div class="detail-value">
|
||||
{{ RecordingUiUtils.formatDuration(recording.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -220,7 +230,9 @@
|
||||
<mat-icon class="detail-icon">storage</mat-icon>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">{{ formatFileSize(recording.size) }}</div>
|
||||
<div class="detail-value">
|
||||
{{ RecordingUiUtils.formatFileSize(recording.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -364,11 +376,16 @@
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let recording">
|
||||
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
|
||||
<mat-icon class="status-icon" [style.color]="getStatusColor(recording.status)">
|
||||
{{ getStatusIcon(recording.status) }}
|
||||
<div class="status-badge" [style.color]="RecordingUiUtils.getStatusColor(recording.status)">
|
||||
<mat-icon
|
||||
class="status-icon"
|
||||
[style.color]="RecordingUiUtils.getStatusColor(recording.status)"
|
||||
>
|
||||
{{ RecordingUiUtils.getStatusIcon(recording.status) }}
|
||||
</mat-icon>
|
||||
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
|
||||
<span class="status-label">{{
|
||||
RecordingUiUtils.getStatusLabel(recording.status)
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
@ -392,7 +409,7 @@
|
||||
<ng-container matColumnDef="duration">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="duration">Duration</th>
|
||||
<td mat-cell *matCellDef="let recording">
|
||||
<span>{{ formatDuration(recording.duration) }}</span>
|
||||
<span>{{ RecordingUiUtils.formatDuration(recording.duration) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -400,7 +417,7 @@
|
||||
<ng-container matColumnDef="size">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="size">Size</th>
|
||||
<td mat-cell *matCellDef="let recording">
|
||||
<span>{{ formatFileSize(recording.size) }}</span>
|
||||
<span>{{ RecordingUiUtils.formatFileSize(recording.size) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -415,7 +432,7 @@
|
||||
mat-icon-button
|
||||
matTooltip="Play Recording"
|
||||
class="action-button play-button"
|
||||
(click)="playRecording(recording)"
|
||||
(click)="$event.stopPropagation(); playRecording(recording)"
|
||||
[disabled]="loading()"
|
||||
id="play-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
@ -429,18 +446,16 @@
|
||||
mat-icon-button
|
||||
matTooltip="Download Recording"
|
||||
class="action-button download-button"
|
||||
(click)="downloadRecording(recording)"
|
||||
(click)="$event.stopPropagation(); downloadRecording(recording)"
|
||||
[disabled]="loading()"
|
||||
id="download-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isRecordingFailed(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="deleteRecording(recording)"
|
||||
(click)="$event.stopPropagation(); deleteRecording(recording)"
|
||||
[disabled]="loading()"
|
||||
class="action-button delete-button"
|
||||
matTooltip="Delete Recording"
|
||||
@ -454,6 +469,7 @@
|
||||
mat-icon-button
|
||||
class="action-button more-button"
|
||||
[matMenuTriggerFor]="actionsMenu"
|
||||
(click)="$event.stopPropagation();"
|
||||
matTooltip="More Actions"
|
||||
[disabled]="loading()"
|
||||
id="more-actions-btn-{{ recording.recordingId }}"
|
||||
@ -464,7 +480,7 @@
|
||||
<mat-menu #actionsMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="shareRecordingLink(recording)"
|
||||
(click)="$event.stopPropagation(); shareRecordingLink(recording)"
|
||||
id="share-recording-link-{{ recording.recordingId }}"
|
||||
>
|
||||
<mat-icon>share</mat-icon>
|
||||
@ -475,7 +491,7 @@
|
||||
<mat-divider></mat-divider>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="deleteRecording(recording)"
|
||||
(click)="$event.stopPropagation(); deleteRecording(recording)"
|
||||
class="delete-action"
|
||||
id="delete-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
@ -494,6 +510,8 @@
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[class.selected-row]="isRecordingSelected(row)"
|
||||
id="table-row-{{ row.recordingId }}"
|
||||
(click)="onRecordingClick(row)"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -18,8 +18,8 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { ViewportService } from 'openvidu-components-angular';
|
||||
import { setsAreEqual } from '../../../../shared/utils/array.utils';
|
||||
import { formatBytes, formatDurationToHMS } from '../../../../shared/utils/format.utils';
|
||||
import { RecordingTableAction, RecordingTableFilter } from '../../models/recording-list.model';
|
||||
import { RecordingUiUtils } from '../../utils/ui';
|
||||
|
||||
/**
|
||||
* Reusable component for displaying a list of recordings with filtering, selection, and bulk operations.
|
||||
@ -87,6 +87,7 @@ export class RecordingListsComponent implements OnInit {
|
||||
|
||||
// Output events
|
||||
@Output() recordingAction = new EventEmitter<RecordingTableAction>();
|
||||
@Output() recordingClicked = new EventEmitter<string>();
|
||||
@Output() filterChange = new EventEmitter<RecordingTableFilter>();
|
||||
@Output() loadMore = new EventEmitter<RecordingTableFilter>();
|
||||
@Output() refresh = new EventEmitter<RecordingTableFilter>();
|
||||
@ -143,6 +144,9 @@ export class RecordingListsComponent implements OnInit {
|
||||
|
||||
protected isMobileView = computed(() => this.viewportService.isMobileView());
|
||||
|
||||
// Make RecordingUiUtils available in template
|
||||
protected readonly RecordingUiUtils = RecordingUiUtils;
|
||||
|
||||
constructor(private viewportService: ViewportService) {
|
||||
effect(() => {
|
||||
// Update selected recordings based on current recordings
|
||||
@ -261,6 +265,10 @@ export class RecordingListsComponent implements OnInit {
|
||||
|
||||
// ===== ACTION METHODS =====
|
||||
|
||||
onRecordingClick(recording: MeetRecordingInfo) {
|
||||
this.recordingClicked.emit(recording.recordingId);
|
||||
}
|
||||
|
||||
playRecording(recording: MeetRecordingInfo) {
|
||||
this.recordingAction.emit({ recordings: [recording], action: 'play' });
|
||||
}
|
||||
@ -349,15 +357,6 @@ export class RecordingListsComponent implements OnInit {
|
||||
return group.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable status label
|
||||
*/
|
||||
getStatusLabel(status: MeetRecordingStatus): string {
|
||||
const statusOption = this.statusOptions.find((option) => option.value === status);
|
||||
const label = statusOption?.label || status;
|
||||
return label.toUpperCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// ===== PERMISSION AND CAPABILITY METHODS =====
|
||||
|
||||
canPlayRecording(recording: MeetRecordingInfo): boolean {
|
||||
@ -375,51 +374,4 @@ export class RecordingListsComponent implements OnInit {
|
||||
isRecordingFailed(recording: MeetRecordingInfo): boolean {
|
||||
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.ERROR);
|
||||
}
|
||||
|
||||
// ===== UI HELPER METHODS =====
|
||||
|
||||
getStatusIcon(status: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'check_circle';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'radio_button_checked';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
return 'hourglass_top';
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'hourglass_bottom';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
return 'error';
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
return 'cancel';
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'warning';
|
||||
default:
|
||||
return 'help';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: MeetRecordingStatus): string {
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.COMPLETED)) {
|
||||
return 'var(--ov-meet-color-success)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ACTIVE)) {
|
||||
return 'var(--ov-meet-color-primary)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.IN_PROGRESS)) {
|
||||
return 'var(--ov-meet-color-warning)';
|
||||
}
|
||||
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ERROR)) {
|
||||
return 'var(--ov-meet-color-error)';
|
||||
}
|
||||
return 'var(--ov-meet-text-secondary)';
|
||||
}
|
||||
|
||||
formatDuration(duration?: number): string {
|
||||
return formatDurationToHMS(duration);
|
||||
}
|
||||
|
||||
formatFileSize(bytes?: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { ViewportService } from 'openvidu-components-angular';
|
||||
import { RecordingUiUtils } from '../../utils/ui';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-recording-video-player',
|
||||
@ -116,31 +117,11 @@ export class RecordingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return RecordingUiUtils.getPlayerStatusIcon(this.recordingStatus);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return RecordingUiUtils.getPlayerStatusMessage(this.recordingStatus);
|
||||
}
|
||||
|
||||
isRecordingInProgress(): boolean {
|
||||
|
||||
@ -3,3 +3,5 @@ export * from './guards';
|
||||
export * from './models';
|
||||
export * from './pages';
|
||||
export * from './services';
|
||||
export * from './utils';
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './recording-detail/recording-detail.component';
|
||||
export * from './recordings/recordings.component';
|
||||
export * from './room-recordings/room-recordings.component';
|
||||
export * from './view-recording/view-recording.component';
|
||||
|
||||
|
||||
@ -0,0 +1,258 @@
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="ov-page-loading">
|
||||
<div class="loading-content">
|
||||
<div class="loading-header">
|
||||
<div class="loading-title">
|
||||
<mat-icon class="ov-recording-icon loading-icon">video_library</mat-icon>
|
||||
<h1>Loading Recording Details</h1>
|
||||
</div>
|
||||
<p class="loading-subtitle">Please wait while we fetch the recording information...</p>
|
||||
</div>
|
||||
<div class="loading-spinner-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (!isLoading() && 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-flat-button color="primary" (click)="navigationService.navigateTo('/recordings')">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to Recordings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Recording Detail Content -->
|
||||
@if (!isLoading() && !hasError() && recording()) {
|
||||
<div class="ov-page-container ov-mb-xxl">
|
||||
<!-- ── Header ───────────────────────────────────────────────── -->
|
||||
<div class="page-header">
|
||||
<div class="header-top">
|
||||
<ov-breadcrumb [items]="breadcrumbItems()"></ov-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<div class="title">
|
||||
<mat-icon class="ov-recording-icon">video_library</mat-icon>
|
||||
<h1>{{ RecordingUiUtils.getDisplayName(recording()!) }}</h1>
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
[style.color]="RecordingUiUtils.getStatusColor(recording()?.status)"
|
||||
[matTooltip]="RecordingUiUtils.getStatusTooltip(recording()?.status)"
|
||||
>
|
||||
<mat-icon class="status-icon">
|
||||
{{ RecordingUiUtils.getStatusIcon(recording()?.status) }}
|
||||
</mat-icon>
|
||||
<span class="status-text">{{ RecordingUiUtils.getStatusLabel(recording()?.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
@if (isComplete) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
class="action-btn"
|
||||
(click)="downloadRecording()"
|
||||
matTooltip="Download recording"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="action-btn"
|
||||
(click)="shareRecording()"
|
||||
matTooltip="Share recording link"
|
||||
>
|
||||
<mat-icon>share</mat-icon>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="icon-action-btn delete-btn"
|
||||
(click)="deleteRecording()"
|
||||
matTooltip="Delete recording"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Key Stats Row ─────────────────────────────────────────── -->
|
||||
<mat-card class="stats-card">
|
||||
<mat-card-content>
|
||||
<div class="stats-grid">
|
||||
<!-- Room -->
|
||||
<div class="stat-item">
|
||||
<mat-icon class="stat-icon">meeting_room</mat-icon>
|
||||
<div class="stat-body">
|
||||
<p class="stat-label">Room</p>
|
||||
<a
|
||||
class="stat-value room-link"
|
||||
[routerLink]="['/rooms', recording()?.roomId, 'detail']"
|
||||
matTooltip="Go to room detail"
|
||||
>
|
||||
{{ recording()?.roomName }}
|
||||
<mat-icon class="nav-icon">open_in_new</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Date -->
|
||||
<div class="stat-item">
|
||||
<mat-icon class="stat-icon">schedule</mat-icon>
|
||||
<div class="stat-body">
|
||||
<p class="stat-label">Started</p>
|
||||
<p class="stat-value">
|
||||
{{
|
||||
recording()?.startDate ? (recording()!.startDate! | date: 'MMM d, y – h:mm a') : '—'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="stat-item">
|
||||
<mat-icon class="stat-icon">timer</mat-icon>
|
||||
<div class="stat-body">
|
||||
<p class="stat-label">Duration</p>
|
||||
<p class="stat-value">{{ RecordingUiUtils.formatDuration(recording()?.duration) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Size -->
|
||||
<div class="stat-item">
|
||||
<mat-icon class="stat-icon">storage</mat-icon>
|
||||
<div class="stat-body">
|
||||
<p class="stat-label">File Size</p>
|
||||
<p class="stat-value">{{ RecordingUiUtils.formatFileSize(recording()?.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- ── Two-column body ───────────────────────────────────────── -->
|
||||
<div class="body-columns">
|
||||
<!-- LEFT — secondary info ─────────────────────────────── -->
|
||||
<mat-card class="info-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="card-title-icon">info</mat-icon>
|
||||
Recording Info
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="info-list">
|
||||
<!-- Recording ID -->
|
||||
<div class="info-row">
|
||||
<span class="info-label">Recording ID</span>
|
||||
<span class="info-value-wrap">
|
||||
<span class="info-value monospace">{{ recording()?.recordingId }}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="copy-btn"
|
||||
(click)="copyRecordingId()"
|
||||
matTooltip="Copy Recording ID"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div class="info-row">
|
||||
<span class="info-label">Ended</span>
|
||||
<span class="info-value">
|
||||
{{ recording()?.endDate ? (recording()!.endDate! | date: 'MMM d, y – h:mm a') : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
@if (recording()?.layout) {
|
||||
<div class="info-row">
|
||||
<span class="info-label">Layout</span>
|
||||
<span class="info-value">{{ recording()?.layout }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filename -->
|
||||
@if (recording()?.filename) {
|
||||
<div class="info-row">
|
||||
<span class="info-label">Filename</span>
|
||||
<span class="info-value monospace">{{ recording()?.filename }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Error / details block -->
|
||||
@if (recording()?.error || recording()?.details) {
|
||||
<mat-divider class="divider"></mat-divider>
|
||||
<div class="error-block">
|
||||
@if (recording()?.error) {
|
||||
<div class="error-row">
|
||||
<mat-icon class="error-icon-sm">error_outline</mat-icon>
|
||||
<div>
|
||||
<p class="info-label">Error</p>
|
||||
<p class="info-value error-text">{{ recording()?.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (recording()?.details) {
|
||||
<div class="error-row">
|
||||
<mat-icon class="error-icon-sm">info_outline</mat-icon>
|
||||
<div>
|
||||
<p class="info-label">Details</p>
|
||||
<p class="info-value">{{ recording()?.details }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- RIGHT — video player ────────────────────────────────── -->
|
||||
<mat-card class="player-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="card-title-icon">play_circle</mat-icon>
|
||||
Preview
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content class="player-content">
|
||||
<ov-recording-video-player
|
||||
[recordingUrl]="recordingUrl()"
|
||||
[recordingStatus]="recording()!.status"
|
||||
[showDownload]="RecordingUiUtils.isComplete(this.recording()?.status)"
|
||||
[showShare]="RecordingUiUtils.isComplete(this.recording()?.status)"
|
||||
(videoError)="onVideoError()"
|
||||
(download)="downloadRecording()"
|
||||
(share)="shareRecording()"
|
||||
/>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,447 @@
|
||||
@use '../../../../../../../../src/assets/styles/design-tokens' as *;
|
||||
|
||||
.ov-page-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Loading State
|
||||
// =====================
|
||||
|
||||
.ov-page-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: var(--ov-meet-spacing-xl);
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-lg);
|
||||
text-align: center;
|
||||
|
||||
.loading-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.loading-icon {
|
||||
@include ov-icon(xl);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--ov-meet-font-size-xl);
|
||||
font-weight: var(--ov-meet-font-weight-regular);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-subtitle {
|
||||
margin: 0;
|
||||
color: var(--ov-meet-text-secondary);
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Error State
|
||||
// =====================
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: var(--ov-meet-spacing-xl);
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
|
||||
.error-icon {
|
||||
@include ov-icon(xl);
|
||||
color: var(--ov-meet-color-error);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
margin: 0;
|
||||
font-size: var(--ov-meet-font-size-xl);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
color: var(--ov-meet-text-secondary);
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Page Header
|
||||
// =====================
|
||||
|
||||
.page-header {
|
||||
@include ov-page-header;
|
||||
|
||||
.header-top {
|
||||
margin-bottom: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--ov-meet-font-size-xxl);
|
||||
font-weight: var(--ov-meet-font-weight-regular);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
|
||||
border-radius: var(--ov-meet-radius-md);
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
background-color: color-mix(in srgb, currentColor 10%, transparent);
|
||||
|
||||
.status-icon {
|
||||
@include ov-icon(sm);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
align-items: center;
|
||||
|
||||
.action-btn {
|
||||
@include ov-button-base;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(sm);
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-action-btn {
|
||||
@include ov-theme-transition;
|
||||
border: 1px solid var(--ov-meet-border-color);
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(sm);
|
||||
}
|
||||
|
||||
&.delete-btn {
|
||||
color: var(--ov-meet-color-error);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Key Stats Row
|
||||
// =====================
|
||||
|
||||
.stats-card {
|
||||
background-color: var(--ov-meet-surface-container);
|
||||
margin-top: var(--ov-meet-spacing-lg);
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--ov-meet-spacing-md);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
padding: var(--ov-meet-spacing-sm) 0;
|
||||
|
||||
.stat-icon {
|
||||
@include ov-icon(md);
|
||||
color: var(--ov-meet-color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Navigable room name link inside the stat
|
||||
.room-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--ov-meet-color-primary);
|
||||
text-decoration: none;
|
||||
@include ov-theme-transition;
|
||||
|
||||
.nav-icon {
|
||||
@include ov-icon(xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Two-column body
|
||||
// =====================
|
||||
|
||||
.body-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--ov-meet-spacing-lg);
|
||||
margin-top: var(--ov-meet-spacing-lg);
|
||||
align-items: start;
|
||||
|
||||
@media (min-width: 900px) {
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
|
||||
// ---- Info Card ----
|
||||
.info-card {
|
||||
@include ov-card;
|
||||
|
||||
mat-card-header {
|
||||
padding-bottom: var(--ov-meet-spacing-sm);
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-text-primary);
|
||||
|
||||
.card-title-icon {
|
||||
@include ov-icon(md);
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--ov-meet-spacing-xs) 0;
|
||||
border-bottom: 1px solid var(--ov-meet-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-primary);
|
||||
margin: 0;
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
word-break: break-word;
|
||||
|
||||
&.monospace {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&.error-text {
|
||||
color: var(--ov-meet-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.info-value-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
|
||||
.copy-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
@include ov-icon(xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--ov-meet-spacing-md) 0;
|
||||
}
|
||||
|
||||
.error-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.error-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.error-icon-sm {
|
||||
@include ov-icon(md);
|
||||
color: var(--ov-meet-color-error);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-primary);
|
||||
margin: 0;
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
|
||||
&.error-text {
|
||||
color: var(--ov-meet-color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Player Card ----
|
||||
.player-card {
|
||||
@include ov-card;
|
||||
|
||||
mat-card-header {
|
||||
padding-bottom: var(--ov-meet-spacing-sm);
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
color: var(--ov-meet-text-primary);
|
||||
|
||||
.card-title-icon {
|
||||
@include ov-icon(md);
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { ILogger, LoggerService } from 'openvidu-components-angular';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../../../shared/components/breadcrumb/breadcrumb.component';
|
||||
import { NavigationService } from '../../../../shared/services/navigation.service';
|
||||
import { NotificationService } from '../../../../shared/services/notification.service';
|
||||
import { RecordingVideoPlayerComponent } from '../../components/recording-video-player/recording-video-player.component';
|
||||
import { RecordingService } from '../../services/recording.service';
|
||||
import { RecordingUiUtils } from '../../utils/ui';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-recording-detail',
|
||||
imports: [
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDividerModule,
|
||||
DatePipe,
|
||||
RouterModule,
|
||||
BreadcrumbComponent,
|
||||
RecordingVideoPlayerComponent
|
||||
],
|
||||
templateUrl: './recording-detail.component.html',
|
||||
styleUrl: './recording-detail.component.scss'
|
||||
})
|
||||
export class RecordingDetailComponent implements OnInit {
|
||||
recording = signal<MeetRecordingInfo | undefined>(undefined);
|
||||
recordingUrl = signal<string | undefined>(undefined);
|
||||
isLoading = signal(true);
|
||||
hasError = signal(false);
|
||||
breadcrumbItems = signal<BreadcrumbItem[]>([]);
|
||||
|
||||
protected log: ILogger;
|
||||
readonly MeetRecordingStatus = MeetRecordingStatus;
|
||||
protected readonly RecordingUiUtils = RecordingUiUtils;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private recordingService: RecordingService,
|
||||
private notificationService: NotificationService,
|
||||
protected navigationService: NavigationService,
|
||||
private clipboard: Clipboard,
|
||||
protected loggerService: LoggerService
|
||||
) {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - RecordingDetailComponent');
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const recordingId = this.route.snapshot.paramMap.get('recordingId');
|
||||
if (!recordingId) {
|
||||
this.hasError.set(true);
|
||||
this.isLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadRecordingDetails(recordingId);
|
||||
}
|
||||
|
||||
private async loadRecordingDetails(recordingId: string) {
|
||||
try {
|
||||
this.isLoading.set(true);
|
||||
const recording = await this.recordingService.getRecording(recordingId);
|
||||
this.recording.set(recording);
|
||||
|
||||
this.breadcrumbItems.set([
|
||||
{
|
||||
label: 'Recordings',
|
||||
action: () => this.navigationService.navigateTo('/recordings')
|
||||
},
|
||||
{
|
||||
label: RecordingUiUtils.getDisplayName(recording)
|
||||
}
|
||||
]);
|
||||
|
||||
if (RecordingUiUtils.isComplete(recording.status)) {
|
||||
this.recordingUrl.set(this.recordingService.getRecordingMediaUrl(recordingId));
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.e('Error loading recording details:', error);
|
||||
this.notificationService.showSnackbar('Failed to load recording details');
|
||||
this.hasError.set(true);
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadRecording() {
|
||||
const rec = this.recording();
|
||||
if (!rec) return;
|
||||
this.recordingService.downloadRecording(rec);
|
||||
}
|
||||
|
||||
async shareRecording() {
|
||||
const rec = this.recording();
|
||||
if (!rec) return;
|
||||
this.recordingService.openShareRecordingDialog(rec.recordingId);
|
||||
}
|
||||
|
||||
copyRecordingId() {
|
||||
const rec = this.recording();
|
||||
if (!rec) return;
|
||||
this.clipboard.copy(rec.recordingId);
|
||||
this.notificationService.showSnackbar('Recording ID copied to clipboard');
|
||||
}
|
||||
|
||||
async deleteRecording() {
|
||||
const rec = this.recording();
|
||||
if (!rec) return;
|
||||
|
||||
try {
|
||||
await this.recordingService.deleteRecording(rec.recordingId);
|
||||
this.notificationService.showSnackbar('Recording deleted successfully');
|
||||
await this.navigationService.navigateTo('/recordings');
|
||||
} catch (error) {
|
||||
this.log.e('Error deleting recording:', error);
|
||||
this.notificationService.showSnackbar('Failed to delete recording');
|
||||
}
|
||||
}
|
||||
|
||||
get isComplete(): boolean {
|
||||
return RecordingUiUtils.isComplete(this.recording()?.status);
|
||||
}
|
||||
|
||||
onVideoError() {
|
||||
this.notificationService.showSnackbar('Error loading video. Please try again.');
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@
|
||||
[showLoadMore]="hasMoreRecordings"
|
||||
[initialFilters]="initialFilters()"
|
||||
(recordingAction)="onRecordingAction($event)"
|
||||
(recordingClicked)="onRecordingClick($event)"
|
||||
(loadMore)="loadMoreRecordings($event)"
|
||||
(refresh)="refreshRecordings($event)"
|
||||
(filterChange)="refreshRecordings($event)"
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MeetRecordingFilters, MeetRecordingInfo } from '@openvidu-meet/typings';
|
||||
import { ILogger, LoggerService } from 'openvidu-components-angular';
|
||||
import { NavigationService } from 'projects/shared-meet-components/src/lib/shared/services/navigation.service';
|
||||
import { NotificationService } from '../../../../shared/services/notification.service';
|
||||
import { RecordingListsComponent } from '../../components/recording-lists/recording-lists.component';
|
||||
import { RecordingTableAction, RecordingTableFilter } from '../../models/recording-list.model';
|
||||
@ -34,14 +35,14 @@ export class RecordingsComponent implements OnInit {
|
||||
hasMoreRecordings = false;
|
||||
private nextPageToken?: string;
|
||||
|
||||
protected loggerService: LoggerService = inject(LoggerService);
|
||||
private recordingService: RecordingService = inject(RecordingService);
|
||||
private notificationService: NotificationService = inject(NotificationService);
|
||||
protected route: ActivatedRoute = inject(ActivatedRoute);
|
||||
protected navigationService: NavigationService = inject(NavigationService);
|
||||
protected log: ILogger;
|
||||
|
||||
constructor(
|
||||
protected loggerService: LoggerService,
|
||||
private recordingService: RecordingService,
|
||||
private notificationService: NotificationService,
|
||||
protected route: ActivatedRoute
|
||||
) {
|
||||
constructor() {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - RecordingsComponent');
|
||||
|
||||
// Get room ID from route query params and set initial filters before component initialization
|
||||
@ -91,6 +92,15 @@ export class RecordingsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async onRecordingClick(recordingId: string) {
|
||||
try {
|
||||
await this.navigationService.navigateTo(`/recordings/${recordingId}`);
|
||||
} catch (error) {
|
||||
this.notificationService.showSnackbar('Error navigating to recording detail');
|
||||
this.log.e('Error navigating to recording detail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRecordings(filters: RecordingTableFilter, refresh = false) {
|
||||
const delayLoader = setTimeout(() => {
|
||||
this.isLoading = true;
|
||||
|
||||
@ -11,9 +11,9 @@ import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { ViewportService } from 'openvidu-components-angular';
|
||||
import { NavigationService } from 'projects/shared-meet-components/src/lib/shared/services/navigation.service';
|
||||
import { NotificationService } from '../../../../shared/services/notification.service';
|
||||
import { formatDurationToTime } from '../../../../shared/utils/format.utils';
|
||||
import { RecordingVideoPlayerComponent } from '../../components/recording-video-player/recording-video-player.component';
|
||||
import { RecordingService } from '../../services/recording.service';
|
||||
import { RecordingUiUtils } from '../../utils/ui';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-view-recording',
|
||||
@ -97,39 +97,16 @@ export class ViewRecordingComponent implements OnInit {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return RecordingUiUtils.getPlayerStatusIcon(this.recording?.status);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return RecordingUiUtils.getPlayerStatusMessage(this.recording.status);
|
||||
}
|
||||
|
||||
formatDuration(duration: number): string {
|
||||
return formatDurationToTime(duration);
|
||||
return RecordingUiUtils.formatDuration(duration);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
|
||||
@ -51,5 +51,14 @@ export const recordingsConsoleRoutes: DomainRouteConfig[] = [
|
||||
iconClass: 'ov-recording-icon',
|
||||
order: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
route: {
|
||||
path: 'recordings/:recordingId',
|
||||
loadComponent: () =>
|
||||
import('../pages/recording-detail/recording-detail.component').then(
|
||||
(m) => m.RecordingDetailComponent
|
||||
)
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
@ -0,0 +1,190 @@
|
||||
import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { formatBytes, formatDurationToHMS } from '../../../shared/utils/format.utils';
|
||||
|
||||
/**
|
||||
* Utility functions for Recording-related UI operations.
|
||||
* These are pure functions that can be used across components and pages.
|
||||
*/
|
||||
export class RecordingUiUtils {
|
||||
// ===== STATUS UTILITIES =====
|
||||
|
||||
/**
|
||||
* Gets the human-readable label for a recording status
|
||||
*/
|
||||
static getStatusLabel(status?: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'Complete';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'Recording';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
return 'Starting';
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'Ending';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
return 'Failed';
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
return 'Aborted';
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'Limit Reached';
|
||||
default:
|
||||
return status ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Material icon name for a recording status
|
||||
*/
|
||||
static getStatusIcon(status?: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'check_circle';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'radio_button_checked';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
return 'hourglass_top';
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'hourglass_bottom';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
return 'error';
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
return 'cancel';
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'warning';
|
||||
default:
|
||||
return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Material icon name for a recording status in a player context
|
||||
* (simplified variant used in the video player overlay)
|
||||
*/
|
||||
static getPlayerStatusIcon(status?: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.STARTING:
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'hourglass_empty';
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'check_circle';
|
||||
default:
|
||||
return 'error_outline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CSS color variable for a recording status
|
||||
*/
|
||||
static getStatusColor(status?: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'var(--ov-meet-color-success)';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'var(--ov-meet-color-primary)';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'var(--ov-meet-color-warning)';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'var(--ov-meet-color-error)';
|
||||
default:
|
||||
return 'var(--ov-meet-text-secondary)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tooltip text for a recording status
|
||||
*/
|
||||
static getStatusTooltip(status?: MeetRecordingStatus): string {
|
||||
switch (status) {
|
||||
case MeetRecordingStatus.COMPLETE:
|
||||
return 'Recording completed successfully and is ready to play';
|
||||
case MeetRecordingStatus.ACTIVE:
|
||||
return 'Recording is currently in progress';
|
||||
case MeetRecordingStatus.STARTING:
|
||||
return 'Recording is being initialized';
|
||||
case MeetRecordingStatus.ENDING:
|
||||
return 'Recording is being finalized';
|
||||
case MeetRecordingStatus.FAILED:
|
||||
return 'Recording failed due to an error';
|
||||
case MeetRecordingStatus.ABORTED:
|
||||
return 'Recording was aborted';
|
||||
case MeetRecordingStatus.LIMIT_REACHED:
|
||||
return 'Recording stopped because a limit was reached';
|
||||
default:
|
||||
return 'Unknown recording status';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player status message for a recording
|
||||
*/
|
||||
static getPlayerStatusMessage(status?: MeetRecordingStatus): string {
|
||||
switch (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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a recording is in a terminal error state
|
||||
*/
|
||||
static isErrorState(status?: MeetRecordingStatus): boolean {
|
||||
return (
|
||||
status === MeetRecordingStatus.FAILED ||
|
||||
status === MeetRecordingStatus.ABORTED ||
|
||||
status === MeetRecordingStatus.LIMIT_REACHED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a recording is playable / downloadable
|
||||
*/
|
||||
static isComplete(status?: MeetRecordingStatus): boolean {
|
||||
return status === MeetRecordingStatus.COMPLETE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a recording is currently being recorded
|
||||
*/
|
||||
static isInProgress(status?: MeetRecordingStatus): boolean {
|
||||
return (
|
||||
status === MeetRecordingStatus.ACTIVE ||
|
||||
status === MeetRecordingStatus.STARTING ||
|
||||
status === MeetRecordingStatus.ENDING
|
||||
);
|
||||
}
|
||||
|
||||
// ===== FORMATTING UTILITIES =====
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string (HH:MM:SS)
|
||||
*/
|
||||
static formatDuration(seconds?: number): string {
|
||||
return formatDurationToHMS(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a file size in bytes to a human-readable string
|
||||
*/
|
||||
static formatFileSize(bytes?: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display name for a recording (filename or recordingId fallback)
|
||||
*/
|
||||
static getDisplayName(recording: MeetRecordingInfo): string {
|
||||
return recording.filename ?? recording.recordingId;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user