frontend: enhance mobile experience with responsive UI in view recording page

This commit is contained in:
Carlos Santos 2025-09-16 12:50:14 +02:00
parent 681cf24e22
commit 06e350d8fb
7 changed files with 436 additions and 67 deletions

View File

@ -121,7 +121,7 @@
}
&.download-button mat-icon {
color: var(--ov-meet-color-primary);
color: var(--ov-meet-color-info);
}
&.delete-button mat-icon {

View File

@ -29,73 +29,78 @@
<!-- Recording Content -->
@else if (recording) {
<div class="ov-page-container">
<div class="recording-page-content fade-in">
<!-- Header Section -->
<div class="recording-header">
<div class="header-content">
<div class="header-info">
<div class="recording-info">
<h1 class="recording-title">{{ recording.roomName }}</h1>
<div class="recording-metadata">
<span class="recording-date">
<mat-icon class="ov-action-icon">schedule</mat-icon>
{{ recording.startDate | date: 'MMM d, y - h:mm a' }}
</span>
@if (recording.duration) {
<span class="recording-duration">
<mat-icon class="ov-action-icon">timer</mat-icon>
{{ formatDuration(recording.duration) }}
</span>
}
</div>
<div class="recording-status">
<mat-icon [class]="'status-icon ' + recording.status">{{ getStatusIcon() }}</mat-icon>
<span class="status-text">{{ getStatusMessage() }}</span>
</div>
</div>
<!-- Actions Section -->
@if (!videoError) {
<div class="actions-section">
<div class="primary-actions">
@if (recordingUrl) {
<button
mat-button
(click)="downloadRecording()"
matTooltip="Download recording"
class="action-btn primary-button"
>
<mat-icon>download</mat-icon>
<span>Download</span>
</button>
}
@if (viewportService.isMobileView()) {
<!-- Mobile Experience -->
<div class="mobile-container">
<!-- Fixed Header Toolbar -->
<div class="mobile-header-toolbar">
<div class="toolbar-content">
<!-- Left: Back button -->
<button mat-icon-button class="back-button" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<button
mat-raised-button
color="accent"
(click)="openShareDialog()"
matTooltip="Share recording"
class="action-btn"
>
<mat-icon>share</mat-icon>
<span>Share</span>
</button>
</div>
</div>
}
<!-- Center: Recording info -->
<div class="recording-info">
<h1 class="recording-title">{{ recording.roomName }}</h1>
<div class="recording-metadata">
<span class="recording-date">
<mat-icon>schedule</mat-icon>
{{ recording.startDate | date: 'MMM d, y' }}
</span>
@if (recording.duration) {
<span class="recording-duration">
<mat-icon>timer</mat-icon>
{{ formatDuration(recording.duration) }}
</span>
}
</div>
</div>
<!-- Right: Actions -->
@if (!videoError && recordingUrl) {
<div class="header-actions">
<button
mat-icon-button
(click)="downloadRecording()"
matTooltip="Download"
class="action-button download-button"
>
<mat-icon>download</mat-icon>
</button>
<button
mat-icon-button
color="accent"
(click)="openShareDialog()"
matTooltip="Share"
class="action-button share-buttosssn"
>
<mat-icon>share</mat-icon>
</button>
</div>
}
</div>
<!-- Status indicator for non-complete recordings -->
@if (recording.status !== 'complete') {
<div class="status-bar">
<mat-icon class="status-icon">{{ getStatusIcon() }}</mat-icon>
<span class="status-text">{{ getStatusMessage() }}</span>
</div>
}
</div>
<!-- Video Player Section -->
<div class="video-section">
<!-- Video Content -->
<div class="mobile-video-content">
@if (recordingUrl && !videoError) {
<div class="video-container">
<video
[src]="recordingUrl"
controls
controlsList="nodownload"
controlsList="nodownload nofullscreen"
playsinline
disablePictureInPicture
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
@ -110,7 +115,7 @@
</div>
} @else if (videoError) {
<div class="video-error-container">
<div class="video-error-content">
<div class="error-content">
<mat-icon class="error-icon">error</mat-icon>
<h3>Video Playback Error</h3>
<p>There was an error loading the video. Please check your connection and try again.</p>
@ -122,7 +127,7 @@
</div>
} @else {
<div class="video-unavailable-container">
<div class="video-unavailable-content">
<div class="unavailable-content">
<mat-icon class="status-icon">{{ getStatusIcon() }}</mat-icon>
<h3>{{ getStatusMessage() }}</h3>
@if (['starting', 'active', 'ending'].includes(recording.status)) {
@ -132,12 +137,127 @@
<span>Refresh</span>
</button>
} @else {
<p>This recording is not available for playback.</p>
<p>This recording is not available for playbook.</p>
}
</div>
</div>
}
</div>
</div>
</div>
} @else {
<!-- Desktop/Tablet Experience -->
<div class="ov-page-container">
<div class="recording-page-content fade-in">
<!-- Header Section -->
<div class="recording-header">
<div class="header-content">
<div class="header-info">
<div class="recording-info">
<h1 class="recording-title">{{ recording.roomName }}</h1>
<div class="recording-metadata">
<span class="recording-date">
<mat-icon class="ov-action-icon">schedule</mat-icon>
{{ recording.startDate | date: 'MMM d, y - h:mm a' }}
</span>
@if (recording.duration) {
<span class="recording-duration">
<mat-icon class="ov-action-icon">timer</mat-icon>
{{ formatDuration(recording.duration) }}
</span>
}
</div>
<div class="recording-status">
<mat-icon [class]="'status-icon ' + recording.status">{{
getStatusIcon()
}}</mat-icon>
<span class="status-text">{{ getStatusMessage() }}</span>
</div>
</div>
<!-- Actions Section -->
@if (!videoError) {
<div class="actions-section">
<div class="primary-actions">
@if (recordingUrl) {
<button
mat-button
(click)="downloadRecording()"
matTooltip="Download recording"
class="action-btn primary-button"
>
<mat-icon>download</mat-icon>
<span>Download</span>
</button>
}
<button
mat-raised-button
color="accent"
(click)="openShareDialog()"
matTooltip="Share recording"
class="action-btn"
>
<mat-icon>share</mat-icon>
<span>Share</span>
</button>
</div>
</div>
}
</div>
</div>
</div>
<!-- Video Player Section -->
<div class="video-section">
@if (recordingUrl && !videoError) {
<div class="video-container">
<video
[src]="recordingUrl"
controls
controlsList="nodownload"
playsinline
class="video-player"
(loadeddata)="onVideoLoaded()"
(error)="onVideoError()"
></video>
@if (!isVideoLoaded) {
<div class="video-loading-overlay">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading video...</span>
</div>
}
</div>
} @else if (videoError) {
<div class="video-error-container">
<div class="video-error-content">
<mat-icon class="error-icon">error</mat-icon>
<h3>Video Playback Error</h3>
<p>There was an error loading the video. Please check your connection and try again.</p>
<button mat-raised-button color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
</button>
</div>
</div>
} @else {
<div class="video-unavailable-container">
<div class="video-unavailable-content">
<mat-icon class="status-icon">{{ getStatusIcon() }}</mat-icon>
<h3>{{ getStatusMessage() }}</h3>
@if (['starting', 'active', 'ending'].includes(recording.status)) {
<p>The recording is still being processed. Please check back in a few minutes.</p>
<button mat-raised-button color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
<span>Refresh</span>
</button>
} @else {
<p>This recording is not available for playback.</p>
}
</div>
</div>
}
</div>
</div>
</div>
}
}

View File

@ -1,5 +1,212 @@
@import '../../../../../../src/assets/styles/design-tokens';
// === MOBILE RESPONSIVE EXPERIENCE ===
.mobile-container {
touch-action: manipulation;
min-height: 100vh;
background-color: var(--ov-meet-background-color);
display: flex;
flex-direction: column;
@include ov-tablet-up {
display: none; // Hide on tablet and desktop
}
}
// === MOBILE HEADER TOOLBAR ===
.mobile-header-toolbar {
position: sticky;
top: 0;
z-index: 1010;
background-color: var(--ov-meet-background-color);
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
padding-top: max(var(--ov-meet-spacing-sm), env(safe-area-inset-top));
}
.toolbar-content {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-md);
min-height: 48px;
.back-button {
// @extend .ov-icon-button;
color: var(--ov-meet-text-primary);
flex-shrink: 0;
mat-icon {
@include ov-icon(md);
}
}
.recording-info {
flex: 1;
min-width: 0;
.recording-title {
margin: 0 0 2px 0;
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
line-height: var(--ov-meet-line-height-tight);
// Truncate long titles
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recording-metadata {
display: flex;
gap: var(--ov-meet-spacing-md);
flex-wrap: wrap;
.recording-date,
.recording-duration {
@include ov-flex-center;
gap: var(--ov-meet-spacing-xs);
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-text-secondary);
mat-icon {
@include ov-icon(xs);
}
}
}
}
.header-actions {
display: flex;
gap: var(--ov-meet-spacing-xs);
flex-shrink: 0;
.action-button {
&.download-button,
&.share-button {
color: var(--ov-meet-text-primary);
}
&.download-button {
color: var(--ov-meet-color-info);
}
mat-icon {
@include ov-icon(md);
}
}
}
}
.status-bar {
@include ov-flex-center;
gap: var(--ov-meet-spacing-xs);
justify-content: center;
background: color-mix(in srgb, var(--ov-meet-color-warning) 12%, transparent);
border-top: 1px solid var(--ov-meet-color-warning);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md);
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-color-warning);
.status-icon {
@include ov-icon(xs);
}
.status-text {
font-weight: var(--ov-meet-font-weight-medium);
}
}
// === MOBILE VIDEO CONTENT ===
.mobile-video-content {
background: var(--ov-meet-background-color);
min-height: inherit;
}
.video-container {
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: var(--ov-meet-background-color);
}
.video-player::-webkit-media-controls-fullscreen-button {
display: none;
}
.video-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include ov-flex-center;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
background: var(--ov-meet-background-color);
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-sm);
}
}
.video-error-container,
.video-unavailable-container {
@include ov-flex-center;
min-height: 300px;
background: var(--ov-meet-surface-color);
border-radius: var(--ov-meet-radius-lg);
border: 1px solid var(--ov-meet-border-color-light);
margin-bottom: var(--ov-meet-spacing-lg);
.error-content,
.unavailable-content {
text-align: center;
max-width: 300px;
padding: var(--ov-meet-spacing-xl);
.error-icon,
.status-icon {
@include ov-icon(xl);
margin-bottom: var(--ov-meet-spacing-md);
}
.error-icon {
color: var(--ov-meet-color-error);
}
.status-icon {
color: var(--ov-meet-text-secondary);
}
h3 {
margin: 0 0 var(--ov-meet-spacing-sm) 0;
font-size: var(--ov-meet-font-size-lg);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
}
p {
margin: 0 0 var(--ov-meet-spacing-lg) 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-relaxed);
}
button {
@include ov-button-base;
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
}
}
}
// === MAIN PAGE LAYOUT ===
.recording-page-content {

View File

@ -1,5 +1,5 @@
import { DatePipe } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -7,7 +7,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationService, RecordingService } from '@lib/services';
import { NotificationService, RecordingService, ViewportService } from '@lib/services';
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
import { formatDurationToTime } from '@lib/utils';
@ -26,7 +26,7 @@ import { formatDurationToTime } from '@lib/utils';
MatSnackBarModule
]
})
export class ViewRecordingComponent implements OnInit {
export class ViewRecordingComponent implements OnInit, OnDestroy {
recording?: MeetRecordingInfo;
recordingUrl?: string;
videoError = false;
@ -34,11 +34,16 @@ export class ViewRecordingComponent implements OnInit {
hasError = false;
isVideoLoaded = false;
// Mobile UI state
showMobileControls = true;
private controlsTimeout?: number;
constructor(
protected recordingService: RecordingService,
protected notificationService: NotificationService,
protected route: ActivatedRoute,
protected router: Router
protected router: Router,
public viewportService: ViewportService
) {}
async ngOnInit() {
@ -72,6 +77,11 @@ export class ViewRecordingComponent implements OnInit {
onVideoLoaded() {
this.isVideoLoaded = true;
this.videoError = false;
// Start controls timeout for mobile
if (this.viewportService.isMobileView()) {
this.resetControlsTimeout();
}
}
onVideoError() {
@ -136,4 +146,33 @@ export class ViewRecordingComponent implements OnInit {
formatDuration(duration: number): string {
return formatDurationToTime(duration);
}
goBack(): void {
// Try to go back in browser history, otherwise navigate to recordings
if (window.history.length > 1) {
window.history.back();
} else {
this.router.navigate(['/recordings']);
}
}
// Mobile UI interactions
private resetControlsTimeout(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
if (this.showMobileControls) {
this.controlsTimeout = window.setTimeout(() => {
this.showMobileControls = false;
}, 3000); // Hide controls after 3 seconds
}
}
ngOnDestroy(): void {
if (this.controlsTimeout) {
clearTimeout(this.controlsTimeout);
}
}
}

View File

@ -14,7 +14,7 @@
--ov-meet-color-accent-dark: #6a1b9a;
--ov-meet-color-warn: #f44336;
--ov-meet-color-success: #4caf50;
--ov-meet-color-info: #045496;
--ov-meet-color-info: #086abb;
--ov-meet-color-warning: #ff9800;
--ov-meet-color-error: #f44336;

View File

@ -37,6 +37,9 @@
// === DARK THEME ===
// Activated when the [data-theme="dark"] attribute is present on html
[data-theme='dark'] {
--ov-meet-color-info: #2a9bf7;
// === SURFACE COLORS - DARK THEME ===
--ov-meet-background-color: #29292e;
--ov-meet-background-secondary: #21212b;

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<title>OpenVidu Meet</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1 maximum-scale=1, user-scalable=no" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet" />