frontend: Implement recording detail page with loading and error states

This commit is contained in:
CSantosM 2026-02-18 17:04:08 +01:00
parent 333bd0e92f
commit b9550aced9
14 changed files with 1118 additions and 132 deletions

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -3,3 +3,5 @@ export * from './guards';
export * from './models';
export * from './pages';
export * from './services';
export * from './utils';

View File

@ -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';

View File

@ -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>
}

View File

@ -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;
}
}
}

View File

@ -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.');
}
}

View File

@ -40,6 +40,7 @@
[showLoadMore]="hasMoreRecordings"
[initialFilters]="initialFilters()"
(recordingAction)="onRecordingAction($event)"
(recordingClicked)="onRecordingClick($event)"
(loadMore)="loadMoreRecordings($event)"
(refresh)="refreshRecordings($event)"
(filterChange)="refreshRecordings($event)"

View File

@ -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;

View File

@ -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 {

View File

@ -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
)
}
}
];

View File

@ -0,0 +1 @@
export * from './ui';

View File

@ -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;
}
}