diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html index 59d32ac2..301569e6 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html @@ -176,11 +176,19 @@
-
- - {{ getStatusIcon(recording.status) }} +
+ + {{ RecordingUiUtils.getStatusIcon(recording.status) }} - {{ getStatusLabel(recording.status) }} + {{ + RecordingUiUtils.getStatusLabel(recording.status) + }}
@@ -212,7 +220,9 @@ timer
Duration
-
{{ formatDuration(recording.duration) }}
+
+ {{ RecordingUiUtils.formatDuration(recording.duration) }} +
@@ -220,7 +230,9 @@ storage
Size
-
{{ formatFileSize(recording.size) }}
+
+ {{ RecordingUiUtils.formatFileSize(recording.size) }} +
@@ -364,11 +376,16 @@ Status -
- - {{ getStatusIcon(recording.status) }} +
+ + {{ RecordingUiUtils.getStatusIcon(recording.status) }} - {{ getStatusLabel(recording.status) }} + {{ + RecordingUiUtils.getStatusLabel(recording.status) + }}
@@ -392,7 +409,7 @@ Duration - {{ formatDuration(recording.duration) }} + {{ RecordingUiUtils.formatDuration(recording.duration) }} @@ -400,7 +417,7 @@ Size - {{ formatFileSize(recording.size) }} + {{ RecordingUiUtils.formatFileSize(recording.size) }} @@ -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 }}" > download } - @if (isRecordingFailed(recording)) {
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.ts index 951bb44e..7e982217 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.ts @@ -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(); + @Output() recordingClicked = new EventEmitter(); @Output() filterChange = new EventEmitter(); @Output() loadMore = new EventEmitter(); @Output() refresh = new EventEmitter(); @@ -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); - } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-video-player/recording-video-player.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-video-player/recording-video-player.component.ts index 617e183e..1fd09a6b 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-video-player/recording-video-player.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-video-player/recording-video-player.component.ts @@ -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 { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/index.ts index bcd37985..4a367e51 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/index.ts @@ -3,3 +3,5 @@ export * from './guards'; export * from './models'; export * from './pages'; export * from './services'; +export * from './utils'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/index.ts index 096d7198..e5086bf0 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/index.ts @@ -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'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.html new file mode 100644 index 00000000..caa729b2 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.html @@ -0,0 +1,258 @@ + +@if (isLoading()) { +
+
+
+
+ video_library +

Loading Recording Details

+
+

Please wait while we fetch the recording information...

+
+
+ +
+
+
+} + + +@if (!isLoading() && hasError()) { +
+
+
+ error_outline +

Recording Not Found

+

The recording you're looking for doesn't exist or is no longer available.

+
+ +
+
+
+
+} + + +@if (!isLoading() && !hasError() && recording()) { +
+ + + + + + +
+ + + + +
+ schedule +
+

Started

+

+ {{ + recording()?.startDate ? (recording()!.startDate! | date: 'MMM d, y – h:mm a') : '—' + }} +

+
+
+ + +
+ timer +
+

Duration

+

{{ RecordingUiUtils.formatDuration(recording()?.duration) }}

+
+
+ + +
+ storage +
+

File Size

+

{{ RecordingUiUtils.formatFileSize(recording()?.size) }}

+
+
+
+
+
+ + +
+ + + + + info + Recording Info + + + +
+ +
+ Recording ID + + {{ recording()?.recordingId }} + + +
+ + +
+ Ended + + {{ recording()?.endDate ? (recording()!.endDate! | date: 'MMM d, y – h:mm a') : '—' }} + +
+ + + @if (recording()?.layout) { +
+ Layout + {{ recording()?.layout }} +
+ } + + + @if (recording()?.filename) { +
+ Filename + {{ recording()?.filename }} +
+ } +
+ + + @if (recording()?.error || recording()?.details) { + +
+ @if (recording()?.error) { +
+ error_outline +
+

Error

+

{{ recording()?.error }}

+
+
+ } + @if (recording()?.details) { +
+ info_outline +
+

Details

+

{{ recording()?.details }}

+
+
+ } +
+ } +
+
+ + + + + + play_circle + Preview + + + + + + +
+
+} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.scss new file mode 100644 index 00000000..f93bbc5b --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.scss @@ -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; + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.ts new file mode 100644 index 00000000..b527883c --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recording-detail/recording-detail.component.ts @@ -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(undefined); + recordingUrl = signal(undefined); + isLoading = signal(true); + hasError = signal(false); + breadcrumbItems = signal([]); + + 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.'); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.html index cb533ee8..c34c4ddc 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.html @@ -40,6 +40,7 @@ [showLoadMore]="hasMoreRecordings" [initialFilters]="initialFilters()" (recordingAction)="onRecordingAction($event)" + (recordingClicked)="onRecordingClick($event)" (loadMore)="loadMoreRecordings($event)" (refresh)="refreshRecordings($event)" (filterChange)="refreshRecordings($event)" diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.ts index 86342012..45df8aef 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/recordings/recordings.component.ts @@ -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; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/view-recording/view-recording.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/view-recording/view-recording.component.ts index 1a2b68db..bff46a51 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/view-recording/view-recording.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/pages/view-recording/view-recording.component.ts @@ -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 { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/routes/recordings.routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/routes/recordings.routes.ts index 32c9ca2e..a15e7c18 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/routes/recordings.routes.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/routes/recordings.routes.ts @@ -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 + ) + } } ]; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/index.ts new file mode 100644 index 00000000..5ecdd1f3 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/index.ts @@ -0,0 +1 @@ +export * from './ui'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/ui.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/ui.ts new file mode 100644 index 00000000..00e5d061 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/utils/ui.ts @@ -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; + } +}