diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/viewport.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/viewport.model.ts new file mode 100644 index 00000000..0e29bb95 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/viewport.model.ts @@ -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; +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts index 9bacdcee..e531e4af 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/openvidu-components-angular.module.ts @@ -22,6 +22,7 @@ import { BroadcastingService } from './services/broadcasting/broadcasting.servic import { GlobalConfigService } from './services/config/global-config.service'; import { OpenViduComponentsConfigService } from './services/config/directive-config.service'; import { OpenViduComponentsUiModule } from './openvidu-components-angular-ui.module'; +import { ViewportService } from './services/viewport/viewport.service'; @NgModule({ imports: [OpenViduComponentsUiModule], @@ -50,6 +51,7 @@ export class OpenViduComponentsModule { RecordingService, StorageService, VirtualBackgroundService, + ViewportService, provideHttpClient(withInterceptorsFromDi()) ]; diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/platform/platform.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/platform/platform.service.ts index 4c6707d1..4d2c7390 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/platform/platform.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/platform/platform.service.ts @@ -1,48 +1,67 @@ import { Injectable } from '@angular/core'; /** + * Service to detect platform, device type, and browser features. * @internal */ @Injectable({ providedIn: 'root' }) export class PlatformService { + private readonly userAgent: string = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + constructor() {} + // ===== Device Type ===== + + /** + * Returns true if the device is mobile (iOS or Android) + */ isMobile(): boolean { return this.isAndroid() || this.isIos(); } - isFirefox(): boolean { - return /Firefox[\/\s](\d+\.\d+)/.test(navigator.userAgent); - } - + /** + * Detect Android Mobile + */ 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 { - return this.isIPhoneOrIPad(navigator?.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; + return this.isIosDevice(this.userAgent); } - 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 ( - /\b(\w*Apple\w*)\b/.test(navigator.vendor) && - /\b(\w*Safari\w*)\b/.test(userAgent) && - !/\b(\w*CriOS\w*)\b/.test(userAgent) && - !/\b(\w*FxiOS\w*)\b/.test(userAgent) - ); + /** + * Detect if the device is an iPhone or iPad + */ + 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; + } + + // ===== 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); } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/viewport/viewport.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/viewport/viewport.service.ts new file mode 100644 index 00000000..ab5b8e45 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/viewport/viewport.service.ts @@ -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(); + + 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(() => { + 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(() => { + 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(() => ({ + 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(); + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts index 3a87cb87..367565e4 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/public-api.ts @@ -38,10 +38,11 @@ export * from './lib/models/recording.model'; export * from './lib/models/data-topic.model'; export * from './lib/models/room.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/lang.model'; export * from './lib/models/theme.model'; +export * from './lib/models/viewport.model'; // Pipes export * from './lib/pipes/participant.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/translate/translate.service'; export * from './lib/services/theme/theme.service'; +export * from './lib/services/viewport/viewport.service'; //Modules export * from './lib/openvidu-components-angular.module'; export * from './lib/openvidu-components-angular-ui.module';