frontend: enhance mobile experience with responsive UI in view recording page
This commit is contained in:
parent
681cf24e22
commit
06e350d8fb
@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
&.download-button mat-icon {
|
||||
color: var(--ov-meet-color-primary);
|
||||
color: var(--ov-meet-color-info);
|
||||
}
|
||||
|
||||
&.delete-button mat-icon {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user