ov-components: add landscape orientation warning component for mobile devices

master
Carlos Santos 2025-09-22 21:04:23 +02:00
parent bab8d3eb2a
commit 0407725437
11 changed files with 293 additions and 165 deletions

View File

@ -0,0 +1,7 @@
<!-- Landscape orientation warning for mobile devices -->
<div id="landscape-warning" [@inOutAnimation]>
<div class="warning-message">
<mat-icon class="warning-icon">screen_rotation</mat-icon>
<span>{{ 'ROOM.LANDSCAPE_WARNING' | translate }}</span>
</div>
</div>

View File

@ -0,0 +1,33 @@
#landscape-warning {
width: 100%;
height: 100%;
background-color: var(--ov-background-color);
opacity: 95%;
align-content: space-evenly;
text-align: center;
color: var(--ov-text-primary-color);
.warning-message {
display: inline-grid;
display: -moz-inline-grid;
place-items: center;
}
mat-icon {
width: 50px;
height: 50px;
font-size: 50px;
margin: auto;
margin-bottom: 10px;
animation: boomerang 1.2s ease-in-out infinite alternate;
@keyframes boomerang {
from {
transform: rotate(0deg);
}
to {
transform: rotate(45deg);
}
}
}
}

View File

@ -0,0 +1,20 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { Component } from '@angular/core';
/**
* Component to display a landscape orientation warning on mobile devices.
* @internal
*/
@Component({
selector: 'ov-landscape-warning',
templateUrl: './landscape-warning.component.html',
styleUrl: './landscape-warning.component.scss',
standalone: false,
animations: [
trigger('inOutAnimation', [
transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]),
transition(':leave', [animate('200ms', style({ opacity: 0 }))])
])
]
})
export class LandscapeWarningComponent {}

View File

@ -1,123 +1,128 @@
<div class="prejoin-container" id="prejoin-container" [class.mobile]="viewportService.isMobile()" [class.name-error]="!!_error">
<!-- Top Language Toolbar -->
<div class="top-toolbar" *ngIf="!isMinimal">
<ov-lang-selector [compact]="false" class="language-selector" (onLangChanged)="onLangChanged.emit($event)"> </ov-lang-selector>
</div>
<!-- Loading State -->
@if (isLoading) {
<div class="loading-overlay">
<div class="loading-content">
<mat-spinner [diameter]="40"></mat-spinner>
<span class="loading-text">{{ 'PREJOIN.PREPARING' | translate }}</span>
</div>
@if (viewportService.shouldShowLandscapeWarning()) {
<ov-landscape-warning></ov-landscape-warning>
} @else {
<div class="prejoin-container" id="prejoin-container" [class.mobile]="viewportService.isMobile()" [class.name-error]="!!_error">
<!-- Top Language Toolbar -->
<div class="top-toolbar" *ngIf="!isMinimal">
<ov-lang-selector [compact]="false" class="language-selector" (onLangChanged)="onLangChanged.emit($event)"> </ov-lang-selector>
</div>
} @else {
<!-- Main Content -->
<div class="prejoin-content">
<!-- Main Card -->
<div class="prejoin-main">
<!-- Video Preview Section -->
<div class="video-preview-section">
<div class="video-preview-container" [@containerResize]="showBackgroundPanel ? 'compact' : 'normal'">
<div class="video-frame">
<ov-media-element
[track]="videoTrack"
[showAvatar]="!isVideoEnabled"
[avatarName]="participantName"
[avatarColor]="'hsl(48, 100%, 50%)'"
[isLocal]="true"
class="video-element"
[id]="videoTrack?.id || 'no-video'"
>
</ov-media-element>
<!-- Video Controls Overlay -->
<div class="video-overlay">
<div class="device-controls">
<div class="control-group" *ngIf="showCameraButton">
<ov-video-devices-select
[compact]="true"
(onVideoDeviceChanged)="videoDeviceChanged($event)"
(onVideoEnabledChanged)="videoEnabledChanged($event)"
(onVideoDevicesLoaded)="onVideoDevicesLoaded($event)"
class="device-selector"
>
</ov-video-devices-select>
<!-- Loading State -->
@if (isLoading) {
<div class="loading-overlay">
<div class="loading-content">
<mat-spinner [diameter]="40"></mat-spinner>
<span class="loading-text">{{ 'PREJOIN.PREPARING' | translate }}</span>
</div>
</div>
} @else {
<!-- Main Content -->
<div class="prejoin-content">
<!-- Main Card -->
<div class="prejoin-main">
<!-- Video Preview Section -->
<div class="video-preview-section">
<div class="video-preview-container" [@containerResize]="showBackgroundPanel ? 'compact' : 'normal'">
<div class="video-frame">
<ov-media-element
[track]="videoTrack"
[showAvatar]="!isVideoEnabled"
[avatarName]="participantName"
[avatarColor]="'hsl(48, 100%, 50%)'"
[isLocal]="true"
class="video-element"
[id]="videoTrack?.id || 'no-video'"
>
</ov-media-element>
<!-- Video Controls Overlay -->
<div class="video-overlay">
<div class="device-controls">
<div class="control-group" *ngIf="showCameraButton">
<ov-video-devices-select
[compact]="true"
(onVideoDeviceChanged)="videoDeviceChanged($event)"
(onVideoEnabledChanged)="videoEnabledChanged($event)"
(onVideoDevicesLoaded)="onVideoDevicesLoaded($event)"
class="device-selector"
>
</ov-video-devices-select>
</div>
<div class="control-group" *ngIf="showMicrophoneButton">
<ov-audio-devices-select
[compact]="true"
(onAudioDeviceChanged)="audioDeviceChanged($event)"
(onAudioEnabledChanged)="audioEnabledChanged($event)"
(onDeviceSelectorClicked)="onDeviceSelectorClicked()"
class="device-selector"
>
</ov-audio-devices-select>
</div>
</div>
<div class="control-group" *ngIf="showMicrophoneButton">
<ov-audio-devices-select
[compact]="true"
(onAudioDeviceChanged)="audioDeviceChanged($event)"
(onAudioEnabledChanged)="audioEnabledChanged($event)"
(onDeviceSelectorClicked)="onDeviceSelectorClicked()"
class="device-selector"
>
</ov-audio-devices-select>
</div>
<!-- Virtual Background Button -->
@if (backgroundEffectEnabled && hasVideoDevices) {
<div class="background-control">
<button
mat-flat-button
class="background-button"
(click)="toggleBackgroundPanel()"
[matTooltip]="'Virtual Backgrounds'"
[disabled]="!isVideoEnabled"
id="backgrounds-button"
>
<mat-icon class="material-symbols-outlined">background_replace</mat-icon>
</button>
</div>
}
</div>
<!-- Virtual Background Button -->
@if (backgroundEffectEnabled && hasVideoDevices) {
<div class="background-control">
<button
mat-flat-button
class="background-button"
(click)="toggleBackgroundPanel()"
[matTooltip]="'Virtual Backgrounds'"
[disabled]="!isVideoEnabled"
id="backgrounds-button"
>
<mat-icon class="material-symbols-outlined">background_replace</mat-icon>
</button>
</div>
}
</div>
</div>
</div>
@if (showBackgroundPanel) {
<div class="vb-container" [@slideInOut]>
<ov-background-effects-panel [mode]="'prejoin'" (onClose)="closeBackgroundPanel()">
</ov-background-effects-panel>
</div>
} @else {
<!-- Configuration Section -->
<div class="configuration-section">
<!-- Participant Name Input -->
<div class="participant-name-container input-section" *ngIf="showParticipantName">
<ov-participant-name-input
[isPrejoinPage]="true"
[error]="!!_error"
(onNameUpdated)="onParticipantNameChanged($event)"
(onEnterPressed)="onEnterPressed()"
class="name-input"
>
</ov-participant-name-input>
</div>
<!-- Error Message -->
<div *ngIf="!!_error" class="error-message" id="token-error">
<mat-icon class="error-icon">error_outline</mat-icon>
<span class="error-text">{{ _error }}</span>
</div>
<!-- Join Button -->
<div class="join-section">
<button
mat-flat-button
(click)="join()"
class="join-button"
id="join-button"
[disabled]="showParticipantName && !participantName"
>
{{ 'PREJOIN.JOIN' | translate }}
</button>
</div>
</div>
}
</div>
@if (showBackgroundPanel) {
<div class="vb-container" [@slideInOut]>
<ov-background-effects-panel [mode]="'prejoin'" (onClose)="closeBackgroundPanel()"> </ov-background-effects-panel>
</div>
} @else {
<!-- Configuration Section -->
<div class="configuration-section">
<!-- Participant Name Input -->
<div class="participant-name-container input-section" *ngIf="showParticipantName">
<ov-participant-name-input
[isPrejoinPage]="true"
[error]="!!_error"
(onNameUpdated)="onParticipantNameChanged($event)"
(onEnterPressed)="onEnterPressed()"
class="name-input"
>
</ov-participant-name-input>
</div>
<!-- Error Message -->
<div *ngIf="!!_error" class="error-message" id="token-error">
<mat-icon class="error-icon">error_outline</mat-icon>
<span class="error-text">{{ _error }}</span>
</div>
<!-- Join Button -->
<div class="join-section">
<button
mat-flat-button
(click)="join()"
class="join-button"
id="join-button"
[disabled]="showParticipantName && !participantName"
>
{{ 'PREJOIN.JOIN' | translate }}
</button>
</div>
</div>
}
</div>
</div>
}
</div>
}
</div>
}

View File

@ -4,14 +4,8 @@
<span>{{ 'ROOM.JOINING' | translate }}</span>
</div>
} @else {
@if (viewportService.isMobile() && viewportService.orientation() === 'landscape') {
<!-- Landscape orientation warning -->
<div id="landscape-warning" [@inOutAnimation]>
<div class="warning-message">
<mat-icon class="warning-icon">screen_rotation</mat-icon>
<span>{{ 'ROOM.LANDSCAPE_WARNING' | translate }}</span>
</div>
</div>
@if (viewportService.shouldShowLandscapeWarning()) {
<ov-landscape-warning></ov-landscape-warning>
}
<div id="session-container" [@inOutAnimation]>
<mat-sidenav-container #container #videoContainer class="sidenav-container">

View File

@ -4,39 +4,6 @@
height: 100%;
}
#landscape-warning {
width: 100%;
height: 100%;
background-color: var(--ov-background-color);
opacity: 95%;
align-content: space-evenly;
text-align: center;
color: var(--ov-text-primary-color);
.warning-message {
display: inline-grid;
display: -moz-inline-grid;
place-items: center;
}
mat-icon {
width: 50px;
height: 50px;
font-size: 50px;
margin: auto;
margin-bottom: 10px;
animation: boomerang 1.2s ease-in-out infinite alternate;
@keyframes boomerang {
from {
transform: rotate(0deg);
}
to {
transform: rotate(45deg);
}
}
}
}
#spinner {
position: absolute;
top: 40%;

View File

@ -24,4 +24,7 @@ export interface ViewportInfo {
isDesktop: boolean;
isWide: boolean;
isTouchDevice: boolean;
isPhysicalMobile: boolean;
isPhysicalTablet: boolean;
shouldShowLandscapeWarning: boolean;
}

View File

@ -47,6 +47,7 @@ import { ApiDirectiveModule } from './directives/api/api.directive.module';
import { OpenViduComponentsDirectiveModule } from './directives/template/openvidu-components-angular.directive.module';
import { AppMaterialModule } from './openvidu-components-angular.material.module';
import { ThemeSelectorComponent } from './components/settings/theme-selector/theme-selector.component';
import { LandscapeWarningComponent } from './components/landscape-warning/landscape-warning.component';
const publicComponents = [
AdminDashboardComponent,
@ -81,7 +82,8 @@ const privateComponents = [
LangSelectorComponent,
ToolbarMediaButtonsComponent,
ToolbarPanelButtonsComponent,
ThemeSelectorComponent
ThemeSelectorComponent,
LandscapeWarningComponent
];
@NgModule({

View File

@ -22,17 +22,60 @@ export class PlatformService {
}
/**
* Detect Android Mobile
* Returns true if the device is physically a mobile device (iPhone, Android phone)
* This method is orientation-independent and hardware-based
*/
isPhysicalMobile(): boolean {
return this.isIPhone() || this.isAndroidPhone();
}
/**
* Returns true if the device is physically a tablet (iPad, Android tablet)
*/
isPhysicalTablet(): boolean {
return this.isIPad() || this.isAndroidTablet();
}
/**
* Detect Android phone specifically (not tablet)
*/
isAndroidPhone(): boolean {
return /\b(\w*Android\w*)\b/.test(this.userAgent) && /\b(\w*Mobile\w*)\b/.test(this.userAgent);
}
/**
* Detect Android tablet specifically
*/
isAndroidTablet(): boolean {
return /\b(\w*Android\w*)\b/.test(this.userAgent) && !/\b(\w*Mobile\w*)\b/.test(this.userAgent);
}
/**
* Detect Android Mobile (legacy method for compatibility)
*/
isAndroid(): boolean {
return /\b(\w*Android\w*)\b/.test(this.userAgent) && /\b(\w*Mobile\w*)\b/.test(this.userAgent);
return this.isAndroidPhone() || this.isAndroidTablet();
}
/**
* Detect iPhone specifically
*/
isIPhone(): boolean {
return /\biPhone\b/.test(this.userAgent) && /\bMobile\b/.test(this.userAgent);
}
/**
* Detect iPad specifically
*/
isIPad(): boolean {
return /\bMacintosh\b/.test(this.userAgent) && 'ontouchend' in document;
}
/**
* Detect iOS device (iPhone or iPad)
*/
isIos(): boolean {
return this.isIosDevice(this.userAgent);
return this.isIPhone() || this.isIPad();
}
/**
@ -43,12 +86,42 @@ export class PlatformService {
}
/**
* Detect if the device is an iPhone or iPad
* Get the maximum screen dimension (useful for detecting device capabilities)
*/
private isIosDevice(userAgent: string): boolean {
const isIPad = /\bMacintosh\b/.test(userAgent) && 'ontouchend' in document;
const isIPhone = /\biPhone\b/.test(userAgent) && /\bMobile\b/.test(userAgent);
return isIPad || isIPhone;
getMaxScreenDimension(): number {
if (typeof screen === 'undefined') return 1024;
return Math.max(screen.width, screen.height);
}
/**
* Get the minimum screen dimension
*/
getMinScreenDimension(): number {
if (typeof screen === 'undefined') return 768;
return Math.min(screen.width, screen.height);
}
/**
* Enhanced mobile detection that considers physical device characteristics
* This is orientation-independent and more reliable for landscape warnings
*/
isPhysicalMobileDevice(): boolean {
// First check: User agent based detection (most reliable)
if (this.isPhysicalMobile()) {
return true;
}
// Second check: Screen dimensions for edge cases
// Most mobile devices have a max dimension <= 950px even in landscape
const maxDimension = this.getMaxScreenDimension();
const minDimension = this.getMinScreenDimension();
// If touch device with small screen dimensions, likely mobile
if (this.isTouchDevice() && maxDimension <= 950 && minDimension <= 500) {
return true;
}
return false;
}
// ===== Browser Detection =====

View File

@ -49,6 +49,17 @@ export class ViewportService implements OnDestroy {
*/
readonly isTouchDevice = computed(() => this.platform.isTouchDevice());
/**
* Whether device is physically a mobile device (orientation-independent)
* This uses hardware detection, not just screen size
*/
readonly isPhysicalMobile = computed(() => this.platform.isPhysicalMobileDevice());
/**
* Whether device is physically a tablet (orientation-independent)
*/
readonly isPhysicalTablet = computed(() => this.platform.isPhysicalTablet());
/**
* Current viewport size category
*/
@ -68,7 +79,8 @@ export class ViewportService implements OnDestroy {
});
/**
* Whether current viewport is mobile size
* Whether current viewport is mobile size (legacy method)
* For landscape warnings, use isPhysicalMobile instead
*/
readonly isMobile = computed(() => this.viewportSize() === 'mobile' && this.platform.isTouchDevice());
@ -77,6 +89,14 @@ export class ViewportService implements OnDestroy {
*/
readonly isTablet = computed(() => this.viewportSize() === 'tablet' && this.platform.isTouchDevice());
/**
* Whether device should show mobile landscape warning
* This is orientation-independent and hardware-based detection
*/
readonly shouldShowLandscapeWarning = computed(() =>
this.isPhysicalMobile() && this.orientation() === 'landscape'
);
/**
* Whether current viewport is desktop size
*/
@ -119,7 +139,10 @@ export class ViewportService implements OnDestroy {
isTablet: this.isTablet(),
isDesktop: this.isDesktop(),
isWide: this.isWide(),
isTouchDevice: this.isTouchDevice()
isTouchDevice: this.isTouchDevice(),
isPhysicalMobile: this.isPhysicalMobile(),
isPhysicalTablet: this.isPhysicalTablet(),
shouldShowLandscapeWarning: this.shouldShowLandscapeWarning()
}));
// ==== PUBLIC UTILITY METHODS ====

View File

@ -18,6 +18,7 @@ export * from './lib/components/toolbar/toolbar.component';
export * from './lib/components/toolbar/toolbar-media-buttons/toolbar-media-buttons.component';
export * from './lib/components/toolbar/toolbar-panel-buttons/toolbar-panel-buttons.component';
export * from './lib/components/videoconference/videoconference.component';
export * from './lib/components/landscape-warning/landscape-warning.component';
export * from './lib/config/openvidu-components-angular.config';
// Directives
export * from './lib/directives/api/activities-panel.directive';