ov-components: Improves recording activity UI

Refactors the recording activity component's template and styles
to use cards for displaying recording information.

Enhances the display of recording metadata, including duration,
size, and date, with appropriate icons.

Adds visual cues for active recordings and improves overall
responsiveness of the recording list.
master
Carlos Santos 2025-07-21 14:12:28 +02:00
parent e486665efd
commit 181c5f0789
3 changed files with 434 additions and 93 deletions

View File

@ -160,50 +160,57 @@
<!-- Recording list --> <!-- Recording list -->
@if (recordingList.length > 0) { @if (recordingList.length > 0) {
<div class="item recording-list-container"> <div class="recording-list-container">
<mat-list>
@for (recording of recordingList; track trackByRecordingId($index, recording)) { @for (recording of recordingList; track trackByRecordingId($index, recording)) {
<mat-list-item class="recording-item" [class.blink]="recording.status === recStatusEnum.STARTED"> <div class="recording-card" [class.recording-active]="recording.status === recStatusEnum.STARTED">
<!-- Recording icon --> <!-- Recording header with status indicator and info -->
<mat-icon class="recording-icon" matListItemIcon>video_file</mat-icon> <div class="recording-header">
<div class="recording-status-indicator">
<!-- Recording title -->
<div matListItemTitle>
<span class="recording-name">{{ recording.filename }}</span>
</div>
<!-- Recording status/progress -->
<div matListItemLine class="time-container">
@if (recording.status === recStatusEnum.STARTED) { @if (recording.status === recStatusEnum.STARTED) {
<span> <div class="status-dot recording-live"></div>
{{ 'PANEL.RECORDING.IN_PROGRESS' | translate }}
</span>
} @else { } @else {
<span class="recording-duration">{{ recording.duration | duration }}</span> <div class="status-dot recording-ready"></div>
<span class="recording-size">| {{ recording.size / 1024 / 1024 | number: '1.1-2' }} MBs</span>
} }
</div> </div>
<!-- Recording date --> <div class="recording-info">
<div class="recording-name">{{ recording.filename || 'Recording' }}</div>
@if (recording.status === recStatusEnum.STARTED) {
<div class="recording-status-text recording-live-text">
{{ 'PANEL.RECORDING.IN_PROGRESS' | translate }}
</div>
} @else {
<div class="recording-metadata">
<span class="metadata-item">
<mat-icon class="metadata-icon">schedule</mat-icon>
{{ formatDuration(recording.duration) }}
</span>
<span class="metadata-item">
<mat-icon class="metadata-icon">storage</mat-icon>
{{ formatFileSize(recording.size) }}
</span>
<span class="metadata-item">
<mat-icon class="metadata-icon">today</mat-icon>
{{ recording.startedAt | date: 'MMM d, y' }}
</span>
</div>
}
</div>
</div>
<!-- Actions menu row -->
@if (recording.status !== recStatusEnum.STARTED) { @if (recording.status !== recStatusEnum.STARTED) {
<div matListItemLine class="recording-date"> <div class="recording-actions-menu">
{{ recording.startedAt | date: 'HH:mm - dd/MM/yyyy' }}
</div>
}
<!-- Recording action buttons -->
@if (!isReadOnlyMode) { @if (!isReadOnlyMode) {
@if (recording.status !== recStatusEnum.STARTED) {
<div id="recording-action-buttons" class="recording-actions">
@if (showControls.play) { @if (showControls.play) {
<button <button
mat-icon-button mat-icon-button
(click)="play(recording)" (click)="play(recording)"
id="play-recording-btn"
matTooltip="{{ 'PANEL.RECORDING.PLAY' | translate }}" matTooltip="{{ 'PANEL.RECORDING.PLAY' | translate }}"
class="action-button play-button" class="action-btn action-play"
> >
<mat-icon>play_arrow</mat-icon> <mat-icon>play_circle</mat-icon>
</button> </button>
} }
@ -211,9 +218,8 @@
<button <button
mat-icon-button mat-icon-button
(click)="onViewRecordingClicked.emit(recording.id)" (click)="onViewRecordingClicked.emit(recording.id)"
id="watch-recording-btn"
matTooltip="{{ 'PANEL.RECORDING.WATCH' | translate }}" matTooltip="{{ 'PANEL.RECORDING.WATCH' | translate }}"
class="action-button watch-button" class="action-btn action-view"
> >
<mat-icon>open_in_new</mat-icon> <mat-icon>open_in_new</mat-icon>
</button> </button>
@ -223,9 +229,8 @@
<button <button
mat-icon-button mat-icon-button
(click)="download(recording)" (click)="download(recording)"
id="download-recording-btn"
matTooltip="{{ 'PANEL.RECORDING.DOWNLOAD' | translate }}" matTooltip="{{ 'PANEL.RECORDING.DOWNLOAD' | translate }}"
class="action-button download-button" class="action-btn action-download"
> >
<mat-icon>download</mat-icon> <mat-icon>download</mat-icon>
</button> </button>
@ -235,33 +240,26 @@
<button <button
mat-icon-button mat-icon-button
(click)="deleteRecording(recording)" (click)="deleteRecording(recording)"
id="delete-recording-btn"
matTooltip="{{ 'PANEL.RECORDING.DELETE' | translate }}" matTooltip="{{ 'PANEL.RECORDING.DELETE' | translate }}"
class="action-button delete-button" class="action-btn action-delete"
> >
<mat-icon>delete</mat-icon> <mat-icon>delete_outline</mat-icon>
</button> </button>
} }
</div>
}
} @else { } @else {
@if (recording.status !== recStatusEnum.STARTED) {
<div id="recording-action-buttons" class="recording-actions">
<button <button
mat-icon-button mat-icon-button
(click)="onViewRecordingClicked.emit(recording.id)" (click)="onViewRecordingClicked.emit(recording.id)"
id="watch-recording-btn"
matTooltip="{{ 'PANEL.RECORDING.WATCH' | translate }}" matTooltip="{{ 'PANEL.RECORDING.WATCH' | translate }}"
class="action-button watch-button" class="action-btn action-view"
> >
<mat-icon>open_in_new</mat-icon> <mat-icon>visibility</mat-icon>
</button> </button>
}
</div> </div>
} }
</div>
} }
</mat-list-item>
}
</mat-list>
</div> </div>
} }
</div> </div>

View File

@ -99,8 +99,236 @@
margin-top: 10px; margin-top: 10px;
} }
// Modern recording list styles
.recording-list-container { .recording-list-container {
margin-top: 10px; display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
max-height: 500px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: var(--ov-accent-action-color);
border-radius: 2px;
opacity: 0.3;
}
&::-webkit-scrollbar-thumb:hover {
opacity: 0.6;
}
}
.recording-card {
background: var(--ov-surface-background-color);
border: 1px solid rgba(0, 102, 204, 0.1);
border-radius: 12px;
padding: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
box-sizing: border-box;
&.recording-active {
border-color: var(--ov-primary-action-color);
background: linear-gradient(135deg,
rgba(255, 87, 34, 0.05) 0%,
transparent 50%);
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--ov-primary-action-color);
animation: pulse-border 2s infinite;
}
}
}
.recording-header {
display: flex;
align-items: flex-start;
gap: 5px;
width: 100%;
height: 60px;
flex-shrink: 0;
}
.recording-status-indicator {
flex-shrink: 0;
padding-top: 2px;
width: 16px;
height: 16px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.recording-live {
background: var(--ov-primary-action-color);
box-shadow: 0 0 0 4px rgba(255, 87, 34, 0.2);
animation: pulse-dot 2s infinite;
}
&.recording-ready {
background: var(--ov-accent-action-color);
}
}
.recording-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
}
.recording-name {
font-size: 14px;
font-weight: 500;
color: var(--ov-text-surface-color);
margin-bottom: 4px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 17px;
}
.recording-status-text {
font-size: 12px;
font-weight: 500;
&.recording-live-text {
color: var(--ov-primary-action-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.recording-metadata {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 4px;
height: auto;
overflow: visible;
}
.metadata-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--ov-text-surface-color);
opacity: 0.7;
white-space: nowrap;
flex-shrink: 0;
.metadata-icon {
font-size: 14px;
width: 14px;
height: 14px;
flex-shrink: 0;
}
}
.recording-actions-menu {
display: flex;
gap: 8px;
flex-shrink: 0;
opacity: 1;
align-items: center;
width: 100%;
justify-content: center;
height: 32px;
margin-top: auto;
}
.action-btn {
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&.action-play {
color: var(--ov-accent-action-color);
&:hover {
background: rgba(0, 102, 204, 0.1);
color: var(--ov-accent-action-color);
}
}
&.action-view {
color: var(--ov-accent-action-color);
&:hover {
background: rgba(0, 102, 204, 0.1);
color: var(--ov-accent-action-color);
}
}
&.action-download {
color: #4CAF50;
&:hover {
background: rgba(76, 175, 80, 0.1);
color: #4CAF50;
}
}
&.action-delete {
color: var(--ov-error-color);
&:hover {
background: rgba(244, 67, 54, 0.1);
color: var(--ov-error-color);
}
}
}
// Animations
@keyframes pulse-dot {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}
@keyframes pulse-border {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }
.recording-actions { .recording-actions {
@ -116,6 +344,62 @@
} }
} }
// Mobile responsive design for new recording cards
@media (max-width: 768px) {
.recording-list-container {
padding-top: 12px;
gap: 12px;
}
.recording-card {
padding: 8px;
height: 100px;
gap: 8px;
}
.recording-header {
gap: 8px;
height: 50px;
}
.recording-info {
min-width: 0;
}
.recording-metadata {
gap: 8px;
margin-top: 2px;
}
.metadata-item {
font-size: 11px;
gap: 2px;
.metadata-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
}
.recording-actions-menu {
opacity: 1; // Always visible on mobile
gap: 6px;
height: 28px;
}
.action-btn {
width: 28px;
height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.recording-message { .recording-message {
color: var(--ov-text-surface-color); color: var(--ov-text-surface-color);
} }
@ -124,19 +408,44 @@
color: var(--ov-error-color); color: var(--ov-error-color);
font-weight: 600; font-weight: 600;
} }
.disable-recording-btn { .disable-recording-btn {
background-color: var(--ov-secondary-action-color) !important; background-color: var(--ov-secondary-action-color) !important;
color: var(--ov-text-surface-color) !important; color: var(--ov-text-surface-color) !important;
cursor: not-allowed !important; cursor: not-allowed !important;
} }
.recording-name {
font-size: 14px; // Enhanced empty state
font-weight: bold; .empty-state {
text-align: center;
padding: 32px 16px;
color: var(--ov-text-surface-color);
} }
.recording-date { .empty-state-icon {
font-size: 12px !important; margin-bottom: 16px;
font-style: italic;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--ov-accent-action-color);
opacity: 0.6;
}
}
.empty-state-title {
font-size: 18px;
font-weight: 500;
margin: 0 0 8px 0;
color: var(--ov-text-surface-color);
}
.empty-state-subtitle {
font-size: 14px;
margin: 0;
opacity: 0.7;
line-height: 1.4;
} }
.not-allowed-message { .not-allowed-message {

View File

@ -304,6 +304,40 @@ export class RecordingActivityComponent implements OnInit, OnDestroy {
this.onViewRecordingsClicked.emit(); this.onViewRecordingsClicked.emit();
} }
/**
* @internal
* Format duration in seconds to a readable format (e.g., "2m 30s")
*/
formatDuration(seconds: number): string {
if (!seconds || seconds < 0) return '0s';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
} else {
return `${remainingSeconds}s`;
}
}
/**
* @internal
* Format file size in bytes to a readable format (e.g., "2.5 MB")
*/
formatFileSize(bytes: number): string {
if (!bytes || bytes < 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(1)} ${sizes[i]}`;
}
private subscribeToConfigChanges() { private subscribeToConfigChanges() {
this.libService.recordingActivityReadOnly$.pipe(takeUntil(this.destroy$)).subscribe((readOnly: boolean) => { this.libService.recordingActivityReadOnly$.pipe(takeUntil(this.destroy$)).subscribe((readOnly: boolean) => {
this.isReadOnlyMode = readOnly; this.isReadOnlyMode = readOnly;