mirror of https://github.com/OpenVidu/openvidu.git
ov-components: add viewport service and model for responsive design detection
parent
c65b5c8a18
commit
677a9129a2
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Viewport size categories based on design system breakpoints
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type ViewportSize = 'mobile' | 'tablet' | 'desktop' | 'wide';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device orientation type
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type DeviceOrientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport information interface
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface ViewportInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: ViewportSize;
|
||||||
|
orientation: DeviceOrientation;
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
isDesktop: boolean;
|
||||||
|
isWide: boolean;
|
||||||
|
isTouchDevice: boolean;
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ import { BroadcastingService } from './services/broadcasting/broadcasting.servic
|
||||||
import { GlobalConfigService } from './services/config/global-config.service';
|
import { GlobalConfigService } from './services/config/global-config.service';
|
||||||
import { OpenViduComponentsConfigService } from './services/config/directive-config.service';
|
import { OpenViduComponentsConfigService } from './services/config/directive-config.service';
|
||||||
import { OpenViduComponentsUiModule } from './openvidu-components-angular-ui.module';
|
import { OpenViduComponentsUiModule } from './openvidu-components-angular-ui.module';
|
||||||
|
import { ViewportService } from './services/viewport/viewport.service';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [OpenViduComponentsUiModule],
|
imports: [OpenViduComponentsUiModule],
|
||||||
|
@ -50,6 +51,7 @@ export class OpenViduComponentsModule {
|
||||||
RecordingService,
|
RecordingService,
|
||||||
StorageService,
|
StorageService,
|
||||||
VirtualBackgroundService,
|
VirtualBackgroundService,
|
||||||
|
ViewportService,
|
||||||
provideHttpClient(withInterceptorsFromDi())
|
provideHttpClient(withInterceptorsFromDi())
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,48 +1,67 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Service to detect platform, device type, and browser features.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
|
private readonly userAgent: string = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
// ===== Device Type =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the device is mobile (iOS or Android)
|
||||||
|
*/
|
||||||
isMobile(): boolean {
|
isMobile(): boolean {
|
||||||
return this.isAndroid() || this.isIos();
|
return this.isAndroid() || this.isIos();
|
||||||
}
|
}
|
||||||
|
|
||||||
isFirefox(): boolean {
|
/**
|
||||||
return /Firefox[\/\s](\d+\.\d+)/.test(navigator.userAgent);
|
* Detect Android Mobile
|
||||||
}
|
*/
|
||||||
|
|
||||||
isAndroid(): boolean {
|
isAndroid(): boolean {
|
||||||
return /\b(\w*Android\w*)\b/.test(navigator.userAgent) && /\b(\w*Mobile\w*)\b/.test(navigator.userAgent);
|
return /\b(\w*Android\w*)\b/.test(this.userAgent) && /\b(\w*Mobile\w*)\b/.test(this.userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect iOS device (iPhone or iPad)
|
||||||
|
*/
|
||||||
isIos(): boolean {
|
isIos(): boolean {
|
||||||
return this.isIPhoneOrIPad(navigator?.userAgent);
|
return this.isIosDevice(this.userAgent);
|
||||||
}
|
|
||||||
private isIPhoneOrIPad(userAgent): boolean {
|
|
||||||
const isIPad = /\b(\w*Macintosh\w*)\b/.test(userAgent);
|
|
||||||
const isIPhone = /\b(\w*iPhone\w*)\b/.test(userAgent) && /\b(\w*Mobile\w*)\b/.test(userAgent);
|
|
||||||
// && /\b(\w*iPhone\w*)\b/.test(navigator.platform);
|
|
||||||
const isTouchable = 'ontouchend' in document;
|
|
||||||
|
|
||||||
return (isIPad || isIPhone) && isTouchable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSafariIos(): boolean {
|
/**
|
||||||
return this.isIos() && this.isIOSWithSafari(navigator?.userAgent);
|
* Detect if the device supports touch interactions
|
||||||
|
*/
|
||||||
|
isTouchDevice(): boolean {
|
||||||
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isIOSWithSafari(userAgent): boolean {
|
/**
|
||||||
return (
|
* Detect if the device is an iPhone or iPad
|
||||||
/\b(\w*Apple\w*)\b/.test(navigator.vendor) &&
|
*/
|
||||||
/\b(\w*Safari\w*)\b/.test(userAgent) &&
|
private isIosDevice(userAgent: string): boolean {
|
||||||
!/\b(\w*CriOS\w*)\b/.test(userAgent) &&
|
const isIPad = /\bMacintosh\b/.test(userAgent) && 'ontouchend' in document;
|
||||||
!/\b(\w*FxiOS\w*)\b/.test(userAgent)
|
const isIPhone = /\biPhone\b/.test(userAgent) && /\bMobile\b/.test(userAgent);
|
||||||
);
|
return isIPad || isIPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Browser Detection =====
|
||||||
|
|
||||||
|
isFirefox(): boolean {
|
||||||
|
return /Firefox[\/\s](\d+\.\d+)/.test(this.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSafariIos(): boolean {
|
||||||
|
return this.isIos() && this.isIOSWithSafari(this.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIOSWithSafari(userAgent: string): boolean {
|
||||||
|
return /\bSafari\b/.test(userAgent) && !/\bCriOS\b/.test(userAgent) && !/\bFxiOS\b/.test(userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
||||||
|
import { fromEvent, Subject, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
import { PlatformService } from '../platform/platform.service';
|
||||||
|
import { DeviceOrientation, ViewportInfo, ViewportSize } from '../../models/viewport.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for responsive viewport detection and device type identification.
|
||||||
|
* Provides reactive signals and utilities for building responsive interfaces.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ViewportService implements OnDestroy {
|
||||||
|
// Design system breakpoints
|
||||||
|
private readonly BREAKPOINTS = {
|
||||||
|
mobile: 480,
|
||||||
|
tablet: 768,
|
||||||
|
desktop: 1024,
|
||||||
|
wide: 1200
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Reactive signals
|
||||||
|
private readonly _width = signal(this.getCurrentWidth());
|
||||||
|
private readonly _height = signal(this.getCurrentHeight());
|
||||||
|
|
||||||
|
// Cleanup subject
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(protected platform: PlatformService) {
|
||||||
|
this.initializeResizeListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== PUBLIC REACTIVE SIGNALS ====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport width (reactive)
|
||||||
|
*/
|
||||||
|
readonly width = this._width.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport height (reactive)
|
||||||
|
*/
|
||||||
|
readonly height = this._height.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether device supports touch interactions (reactive)
|
||||||
|
*/
|
||||||
|
readonly isTouchDevice = computed(() => this.platform.isTouchDevice());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current viewport size category (computed)
|
||||||
|
*/
|
||||||
|
readonly viewportSize = computed<ViewportSize>(() => {
|
||||||
|
const width = this._width();
|
||||||
|
if (width >= this.BREAKPOINTS.wide) return 'wide';
|
||||||
|
if (width >= this.BREAKPOINTS.desktop) return 'desktop';
|
||||||
|
if (width >= this.BREAKPOINTS.tablet) return 'tablet';
|
||||||
|
return 'mobile';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device orientation (computed)
|
||||||
|
*/
|
||||||
|
readonly orientation = computed<DeviceOrientation>(() => {
|
||||||
|
return this._width() > this._height() ? 'landscape' : 'portrait';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is mobile size (computed)
|
||||||
|
*/
|
||||||
|
readonly isMobile = computed(() => this.viewportSize() === 'mobile');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet size (computed)
|
||||||
|
*/
|
||||||
|
readonly isTablet = computed(() => this.viewportSize() === 'tablet');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is desktop size (computed)
|
||||||
|
*/
|
||||||
|
readonly isDesktop = computed(() => this.viewportSize() === 'desktop');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is wide desktop size (computed)
|
||||||
|
*/
|
||||||
|
readonly isWide = computed(() => this.viewportSize() === 'wide');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is mobile or smaller (computed)
|
||||||
|
*/
|
||||||
|
readonly isMobileView = computed(() => this._width() < this.BREAKPOINTS.tablet);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet or smaller (computed)
|
||||||
|
*/
|
||||||
|
readonly isTabletDown = computed(() => this._width() < this.BREAKPOINTS.desktop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is tablet or larger (computed)
|
||||||
|
*/
|
||||||
|
readonly isTabletUp = computed(() => this._width() >= this.BREAKPOINTS.tablet);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether current viewport is desktop or larger (computed)
|
||||||
|
*/
|
||||||
|
readonly isDesktopUp = computed(() => this._width() >= this.BREAKPOINTS.desktop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete viewport information (computed)
|
||||||
|
*/
|
||||||
|
readonly viewportInfo = computed<ViewportInfo>(() => ({
|
||||||
|
width: this._width(),
|
||||||
|
height: this._height(),
|
||||||
|
size: this.viewportSize(),
|
||||||
|
orientation: this.orientation(),
|
||||||
|
isMobile: this.isMobile(),
|
||||||
|
isTablet: this.isTablet(),
|
||||||
|
isDesktop: this.isDesktop(),
|
||||||
|
isWide: this.isWide(),
|
||||||
|
isTouchDevice: this.isTouchDevice()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==== PUBLIC UTILITY METHODS ====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport matches specific size
|
||||||
|
*/
|
||||||
|
matchesSize(size: ViewportSize): boolean {
|
||||||
|
return this.viewportSize() === size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport is smaller than specified size
|
||||||
|
*/
|
||||||
|
isSmallerThan(size: ViewportSize): boolean {
|
||||||
|
const currentWidth = this._width();
|
||||||
|
return currentWidth < this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if viewport is larger than specified size
|
||||||
|
*/
|
||||||
|
isLargerThan(size: ViewportSize): boolean {
|
||||||
|
const currentWidth = this._width();
|
||||||
|
return currentWidth >= this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get responsive grid columns based on viewport and content count
|
||||||
|
*/
|
||||||
|
getGridColumns(itemCount = 0): string {
|
||||||
|
if (this.isMobileView()) {
|
||||||
|
return 'single-column';
|
||||||
|
}
|
||||||
|
if (this.isTablet()) {
|
||||||
|
return itemCount > 6 ? 'two-columns' : 'single-column';
|
||||||
|
}
|
||||||
|
return itemCount > 10 ? 'three-columns' : 'two-columns';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate icon size for current viewport
|
||||||
|
*/
|
||||||
|
getIconSize(): 'small' | 'medium' | 'large' {
|
||||||
|
if (this.isMobileView()) return 'medium';
|
||||||
|
if (this.isTablet()) return 'small';
|
||||||
|
return 'small';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate spacing size for current viewport
|
||||||
|
*/
|
||||||
|
getSpacing(): 'compact' | 'comfortable' | 'spacious' {
|
||||||
|
if (this.isMobileView()) return 'compact';
|
||||||
|
if (this.isTablet()) return 'comfortable';
|
||||||
|
return 'spacious';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device is in landscape mode (mobile context)
|
||||||
|
*/
|
||||||
|
isLandscape(): boolean {
|
||||||
|
return this.orientation() === 'landscape';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device is in portrait mode
|
||||||
|
*/
|
||||||
|
isPortrait(): boolean {
|
||||||
|
return this.orientation() === 'portrait';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get breakpoint value for specified size
|
||||||
|
*/
|
||||||
|
getBreakpoint(size: keyof typeof this.BREAKPOINTS): number {
|
||||||
|
return this.BREAKPOINTS[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== PRIVATE METHODS ====
|
||||||
|
|
||||||
|
private getCurrentWidth(): number {
|
||||||
|
return typeof window !== 'undefined' ? window.innerWidth : 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentHeight(): number {
|
||||||
|
return typeof window !== 'undefined' ? window.innerHeight : 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectTouchDevice(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeResizeListener(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
fromEvent(window, 'resize')
|
||||||
|
.pipe(
|
||||||
|
debounceTime(150), // Debounce for performance
|
||||||
|
distinctUntilChanged(),
|
||||||
|
takeUntil(this.destroy$)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this._width.set(this.getCurrentWidth());
|
||||||
|
this._height.set(this.getCurrentHeight());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,10 +38,11 @@ export * from './lib/models/recording.model';
|
||||||
export * from './lib/models/data-topic.model';
|
export * from './lib/models/data-topic.model';
|
||||||
export * from './lib/models/room.model';
|
export * from './lib/models/room.model';
|
||||||
export * from './lib/models/toolbar.model';
|
export * from './lib/models/toolbar.model';
|
||||||
export * from './lib/models/logger.model'
|
export * from './lib/models/logger.model';
|
||||||
export * from './lib/models/storage.model';
|
export * from './lib/models/storage.model';
|
||||||
export * from './lib/models/lang.model';
|
export * from './lib/models/lang.model';
|
||||||
export * from './lib/models/theme.model';
|
export * from './lib/models/theme.model';
|
||||||
|
export * from './lib/models/viewport.model';
|
||||||
// Pipes
|
// Pipes
|
||||||
export * from './lib/pipes/participant.pipe';
|
export * from './lib/pipes/participant.pipe';
|
||||||
export * from './lib/pipes/recording.pipe';
|
export * from './lib/pipes/recording.pipe';
|
||||||
|
@ -60,6 +61,7 @@ export * from './lib/services/logger/logger.service';
|
||||||
export * from './lib/services/storage/storage.service';
|
export * from './lib/services/storage/storage.service';
|
||||||
export * from './lib/services/translate/translate.service';
|
export * from './lib/services/translate/translate.service';
|
||||||
export * from './lib/services/theme/theme.service';
|
export * from './lib/services/theme/theme.service';
|
||||||
|
export * from './lib/services/viewport/viewport.service';
|
||||||
//Modules
|
//Modules
|
||||||
export * from './lib/openvidu-components-angular.module';
|
export * from './lib/openvidu-components-angular.module';
|
||||||
export * from './lib/openvidu-components-angular-ui.module';
|
export * from './lib/openvidu-components-angular-ui.module';
|
||||||
|
|
Loading…
Reference in New Issue