mirror of https://github.com/OpenVidu/openvidu.git
ov-components: add landscape orientation warning component for mobile devices
parent
bab8d3eb2a
commit
0407725437
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -24,4 +24,7 @@ export interface ViewportInfo {
|
|||
isDesktop: boolean;
|
||||
isWide: boolean;
|
||||
isTouchDevice: boolean;
|
||||
isPhysicalMobile: boolean;
|
||||
isPhysicalTablet: boolean;
|
||||
shouldShowLandscapeWarning: boolean;
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 =====
|
||||
|
|
|
@ -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 ====
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue