From 961867941af8e6692d489f02a40260d91974c0f2 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 22 Dec 2025 18:03:45 +0100 Subject: [PATCH] ov-component: introduce layout management classes and caching mechanism - Added LayoutDimensionsCache for caching dimension calculations to optimize layout rendering. - Implemented LayoutRenderer for handling DOM manipulation and rendering of layout elements. - Created layout-types model to define various layout-related types and constants. - Developed OpenViduLayout class to orchestrate layout calculations and rendering, maintaining backward compatibility. - Updated document and layout services to reference new layout model structure. --- openvidu-components-angular/package-lock.json | 3 + .../components/session/session.component.ts | 2 +- .../src/lib/models/layout.model.ts | 1023 ----------------- .../models/layout/layout-calculator.model.ts | 667 +++++++++++ .../layout/layout-dimensions-cache.model.ts | 76 ++ .../models/layout/layout-renderer.model.ts | 196 ++++ .../lib/models/layout/layout-types.model.ts | 192 ++++ .../src/lib/models/layout/layout.model.ts | 222 ++++ .../lib/services/document/document.service.ts | 2 +- .../src/lib/services/layout/layout.service.ts | 2 +- .../src/public-api.ts | 2 +- 11 files changed, 1360 insertions(+), 1027 deletions(-) delete mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout.model.ts create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-calculator.model.ts create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-dimensions-cache.model.ts create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-renderer.model.ts create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-types.model.ts create mode 100644 openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout.model.ts diff --git a/openvidu-components-angular/package-lock.json b/openvidu-components-angular/package-lock.json index 2af000386..eb5b8cd09 100644 --- a/openvidu-components-angular/package-lock.json +++ b/openvidu-components-angular/package-lock.json @@ -14262,6 +14262,7 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -21040,6 +21041,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -21368,6 +21370,7 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts index 464023608..3ef597d6a 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/session/session.component.ts @@ -17,7 +17,7 @@ import { animate, style, transition, trigger } from '@angular/animations'; import { MatDrawerContainer, MatSidenav } from '@angular/material/sidenav'; import { skip, Subject, takeUntil } from 'rxjs'; import { DataTopic } from '../../models/data-topic.model'; -import { SidenavMode } from '../../models/layout.model'; +import { SidenavMode } from '../../models/layout/layout.model'; import { ILogger } from '../../models/logger.model'; import { PanelStatusInfo, PanelType } from '../../models/panel.model'; import { RoomStatusData } from '../../models/room.model'; diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout.model.ts deleted file mode 100644 index f71e4a415..000000000 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout.model.ts +++ /dev/null @@ -1,1023 +0,0 @@ -/** - * @internal - */ -export enum LayoutClass { - ROOT_ELEMENT = 'OV_root', - BIG_ELEMENT = 'OV_big', - SMALL_ELEMENT = 'OV_small', - TOP_BAR_ELEMENT = 'OV_top-bar', - IGNORED_ELEMENT = 'OV_ignored', - MINIMIZED_ELEMENT = 'OV_minimized', - SIDENAV_CONTAINER = 'sidenav-container', - NO_SIZE_ELEMENT = 'no-size', - CLASS_NAME = 'layout' -} - -/** - * @internal - */ -export enum SidenavMode { - OVER = 'over', - SIDE = 'side' -} - -/** - * @internal - */ -export enum LayoutAlignment { - START = 'start', - CENTER = 'center', - END = 'end' -} - -/** - * Layout position options for big elements - */ -export type BigFirstOption = boolean | 'column' | 'row'; - -/** - * Element dimensions interface - */ -export interface ElementDimensions { - height: number; - width: number; - big?: boolean; - small?: boolean; - topBar?: boolean; -} - -/** - * Layout area definition - */ -export interface LayoutArea { - top: number; - left: number; - width: number; - height: number; -} - -/** - * Layout box positioning - */ -export interface LayoutBox extends LayoutArea {} - -/** - * Row structure for layout calculations - */ -export interface LayoutRow { - ratios: number[]; - width: number; - height: number; -} - -/** - * Best dimensions calculation result - */ -export interface BestDimensions { - maxArea: number; - targetCols: number; - targetRows: number; - targetHeight: number; - targetWidth: number; - ratio: number; -} - -/** - * Extended layout options with container dimensions - */ -export interface ExtendedLayoutOptions extends OpenViduLayoutOptions { - containerWidth: number; - containerHeight: number; -} - -/** - * Layout configuration constants - */ -export const LAYOUT_CONSTANTS = { - DEFAULT_VIDEO_WIDTH: 640, - DEFAULT_VIDEO_HEIGHT: 480, - DEFAULT_MAX_RATIO: 3 / 2, - DEFAULT_MIN_RATIO: 9 / 16, - DEFAULT_BIG_PERCENTAGE: 0.8, - UPDATE_TIMEOUT: 50, - ANIMATION_DURATION: '0.1s', - ANIMATION_EASING: 'linear' -} as const; - -/** - * @internal - */ -export interface OpenViduLayoutOptions { - /** The narrowest ratio that will be used (2x3 by default) */ - maxRatio: number; - /** The widest ratio that will be used (16x9 by default) */ - minRatio: number; - /** If true, aspect ratio is maintained and minRatio/maxRatio are ignored */ - fixedRatio: boolean; - /** Whether to animate transitions */ - animate: boolean; - /** Class for elements that should be sized bigger */ - bigClass: string; - /** Class for elements that should be sized smaller */ - smallClass: string; - /** Class for elements that should be ignored */ - ignoredClass: string; - /** Maximum percentage of space big elements should take up */ - bigPercentage: number; - /** Minimum percentage for big space to scale down whitespace */ - minBigPercentage: number; - /** Fixed ratio for big elements */ - bigFixedRatio: boolean; - /** Narrowest ratio for big elements */ - bigMaxRatio: number; - /** Widest ratio for big elements */ - bigMinRatio: number; - /** Position preference for big elements */ - bigFirst: BigFirstOption; - /** Alignment for all elements */ - alignItems: LayoutAlignment; - /** Alignment for big elements */ - bigAlignItems: LayoutAlignment; - /** Alignment for small elements */ - smallAlignItems: LayoutAlignment; - /** Maximum width of elements */ - maxWidth: number; - /** Maximum height of elements */ - maxHeight: number; - /** Maximum width for small elements */ - smallMaxWidth: number; - /** Maximum height for small elements */ - smallMaxHeight: number; - /** Maximum width for big elements */ - bigMaxWidth: number; - /** Maximum height for big elements */ - bigMaxHeight: number; - /** Scale up elements in last row if fewer elements */ - scaleLastRow: boolean; - /** Scale up big elements in last row */ - bigScaleLastRow: boolean; -} - -/** - * @internal - */ -export class OpenViduLayout { - private layoutContainer!: HTMLElement; - private opts!: OpenViduLayoutOptions; - private dimensionsCache = new Map(); - - /** - * Update the layout container - * module export layout - */ - updateLayout(container: HTMLElement, opts: OpenViduLayoutOptions) { - setTimeout(() => { - this.layoutContainer = container; - this.opts = opts; - - if (this.getCssProperty(this.layoutContainer, 'display') === 'none') { - return; - } - let id = this.layoutContainer.id; - if (!id) { - id = 'OV_' + this.cheapUUID(); - this.layoutContainer.id = id; - } - - const extendedOpts: ExtendedLayoutOptions = { - ...opts, - containerHeight: this.getHeight(this.layoutContainer) - - this.getCSSNumber(this.layoutContainer, 'border-top') - - this.getCSSNumber(this.layoutContainer, 'border-bottom'), - containerWidth: this.getWidth(this.layoutContainer) - - this.getCSSNumber(this.layoutContainer, 'border-left') - - this.getCSSNumber(this.layoutContainer, 'border-right') - }; - - const selector = `#${id}>*:not(.${LayoutClass.IGNORED_ELEMENT}):not(.${LayoutClass.MINIMIZED_ELEMENT})`; - const children = Array.prototype.filter.call( - this.layoutContainer.querySelectorAll(selector), - () => this.filterDisplayNone - ); - const elements = children.map((element) => { - const res = this.getChildDims(element); - res.big = element.classList.contains(this.opts.bigClass); - res.small = element.classList.contains(LayoutClass.SMALL_ELEMENT); - res.topBar = element.classList.contains(LayoutClass.TOP_BAR_ELEMENT); - return res; - }); - - const layout = this.getLayout(extendedOpts, elements); - layout.boxes.forEach((box, idx) => { - const elem = children[idx]; - this.getCssProperty(elem, 'position', 'absolute'); - const actualWidth = - box.width - - -this.getCSSNumber(elem, 'margin-left') - - this.getCSSNumber(elem, 'margin-right') - - (this.getCssProperty(elem, 'box-sizing') !== 'border-box' - ? this.getCSSNumber(elem, 'padding-left') + - this.getCSSNumber(elem, 'padding-right') + - this.getCSSNumber(elem, 'border-left') + - this.getCSSNumber(elem, 'border-right') - : 0); - - const actualHeight = - box.height - - -this.getCSSNumber(elem, 'margin-top') - - this.getCSSNumber(elem, 'margin-bottom') - - (this.getCssProperty(elem, 'box-sizing') !== 'border-box' - ? this.getCSSNumber(elem, 'padding-top') + - this.getCSSNumber(elem, 'padding-bottom') + - this.getCSSNumber(elem, 'border-top') + - this.getCSSNumber(elem, 'border-bottom') - : 0); - - this.positionElement(elem, box.left, box.top, actualWidth, actualHeight, this.opts.animate); - }); - }, LAYOUT_CONSTANTS.UPDATE_TIMEOUT); - } - - /** - * Initialize the layout inside of the container with the options required - * @param container - * @param opts - */ - initLayoutContainer(container: HTMLElement, opts: OpenViduLayoutOptions) { - this.opts = opts; - this.layoutContainer = container; - this.updateLayout(container, opts); - } - - getLayoutContainer(): HTMLElement { - return this.layoutContainer; - } - - /** - * Clear dimensions cache to free memory - */ - clearCache(): void { - this.dimensionsCache.clear(); - } - - - - private getCssProperty(el: HTMLVideoElement | HTMLElement, propertyName: any, value?: string): void | string { - if (value !== undefined) { - // Set one CSS property - el.style[propertyName] = value; - } else if (typeof propertyName === 'object') { - // Set several CSS properties at once - Object.keys(propertyName).forEach((key) => { - this.getCssProperty(el, key, propertyName[key]); - }); - } else { - // Get the CSS property - const computedStyle = window.getComputedStyle(el); - let currentValue = computedStyle.getPropertyValue(propertyName); - - if (currentValue === '') { - currentValue = el.style[propertyName]; - } - return currentValue; - } - } - - private height(el: HTMLElement) { - const { offsetHeight } = el; - - if (offsetHeight > 0) { - return `${offsetHeight}px`; - } - return this.getCssProperty(el, 'height'); - } - - private width(el: HTMLElement) { - const { offsetWidth } = el; - - if (offsetWidth > 0) { - return `${offsetWidth}px`; - } - return this.getCssProperty(el, 'width'); - } - - - - /** - * @hidden - */ - private fixAspectRatio(elem: HTMLVideoElement, width: number): void { - const sub = elem.querySelector(`.${LayoutClass.ROOT_ELEMENT}`) as HTMLVideoElement; - if (sub) { - // If this is the parent of a subscriber or publisher, then we need - // to force the mutation observer on the publisher or subscriber to - // trigger to get it to fix its layout - const oldWidth = sub.style.width; - sub.style.width = `${width}px`; - // sub.style.height = height + 'px'; - sub.style.width = oldWidth || ''; - } - } - - /** - * @hidden - */ - private positionElement(elem: HTMLVideoElement, x: number, y: number, width: number, height: number, animate: boolean) { - const targetPosition = { - left: `${x}px`, - top: `${y}px`, - width: `${width}px`, - height: `${height}px` - }; - - this.fixAspectRatio(elem, width); - - if (animate) { - setTimeout(() => { - // animation added in css transition: all .1s linear; - this.animateElement(elem, targetPosition); - this.fixAspectRatio(elem, width); - }, 10); - } else { - this.setElementPosition(elem, targetPosition); - if (!elem.classList.contains(LayoutClass.CLASS_NAME)) { - elem.classList.add(LayoutClass.CLASS_NAME); - } - } - this.fixAspectRatio(elem, width); - } - - private setElementPosition(elem: HTMLVideoElement, targetPosition: { [key: string]: string }) { - Object.keys(targetPosition).forEach((key) => { - (elem.style as any)[key] = targetPosition[key]; - }); - } - - private animateElement(elem: HTMLVideoElement, targetPosition: { [key: string]: string }) { - elem.style.transition = `all ${LAYOUT_CONSTANTS.ANIMATION_DURATION} ${LAYOUT_CONSTANTS.ANIMATION_EASING}`; - this.setElementPosition(elem, targetPosition); - } - - /** - * @hidden - */ - private getChildDims(child: HTMLVideoElement | HTMLElement): ElementDimensions { - if (child instanceof HTMLVideoElement) { - if (child.videoHeight && child.videoWidth) { - return { - height: child.videoHeight, - width: child.videoWidth - }; - } - } else if (child instanceof HTMLElement) { - const video = child.querySelector('video'); - if (video instanceof HTMLVideoElement && video.videoHeight && video.videoWidth) { - return { - height: video.videoHeight, - width: video.videoWidth - }; - } - } - return { - height: LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, - width: LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH - }; - } - - /** - * @hidden - */ - private getCSSNumber(elem: HTMLElement, prop: string): number { - const cssStr = this.getCssProperty(elem, prop); - return cssStr ? parseInt(cssStr, 10) : 0; - } - - /** - * @hidden - */ - // Really cheap UUID function - private cheapUUID(): string { - return Math.floor(Math.random() * 100000000).toString(); - } - - /** - * @hidden - */ - private getHeight(elem: HTMLElement): number { - const heightStr = this.height(elem); - return heightStr ? parseInt(heightStr, 10) : 0; - } - - /** - * @hidden - */ - private getWidth(elem: HTMLElement): number { - const widthStr = this.width(elem); - return widthStr ? parseInt(widthStr, 10) : 0; - } - - - - /** - * @hidden - */ - private filterDisplayNone(element: HTMLElement) { - return this.getCssProperty(element, 'display') !== 'none'; - } - - /** - * - * -------------------------------------------------------------------------------- - * - * GET LAYOUT - * - * - */ - - /** - * @hidden - */ - private getBestDimensions( - minRatio: number, - maxRatio: number, - width: number, - height: number, - count: number, - maxWidth: number, - maxHeight: number - ): BestDimensions { - // Cache key for memoization - const cacheKey = `${minRatio}_${maxRatio}_${width}_${height}_${count}_${maxWidth}_${maxHeight}`; - const cached = this.dimensionsCache.get(cacheKey); - if (cached) { - return cached; - } - let bestArea = 0; - let bestCols = 1; - let bestRows = 1; - let bestHeight = 0; - let bestWidth = 0; - - // Optimized: limit search space based on aspect ratio constraints - const maxCols = Math.min(count, Math.ceil(Math.sqrt(count * width / height))); - - for (let cols = 1; cols <= maxCols; cols++) { - const rows = Math.ceil(count / cols); - - // Early exit if too many rows for the height - if (rows > height / 10) continue; - - let elementWidth = Math.floor(width / cols); - let elementHeight = Math.floor(height / rows); - - const ratio = elementHeight / elementWidth; - - // Apply ratio constraints - if (ratio > maxRatio) { - elementHeight = elementWidth * maxRatio; - } else if (ratio < minRatio) { - elementWidth = elementHeight / minRatio; - } - - // Apply size constraints - elementWidth = Math.min(maxWidth, elementWidth); - elementHeight = Math.min(maxHeight, elementHeight); - - const area = elementWidth * elementHeight * count; - - // Favor layouts with better utilization and fewer empty cells - const efficiency = count / (cols * rows); - const adjustedArea = area * efficiency; - - if (adjustedArea > bestArea) { - bestArea = area; - bestHeight = elementHeight; - bestWidth = elementWidth; - bestCols = cols; - bestRows = rows; - } - } - - const result: BestDimensions = { - maxArea: bestArea, - targetCols: bestCols, - targetRows: bestRows, - targetHeight: bestHeight, - targetWidth: bestWidth, - ratio: bestHeight / bestWidth || 0 - }; - - // Cache the result for future use - this.dimensionsCache.set(cacheKey, result); - - return result; - } - - private getVideoRatio(element: ElementDimensions): number { - return element.height / element.width; - } - private getLayout(opts: ExtendedLayoutOptions, elements: ElementDimensions[]) { - const { - maxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, - minRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, - fixedRatio = false, - bigPercentage = LAYOUT_CONSTANTS.DEFAULT_BIG_PERCENTAGE, - minBigPercentage = 0, - bigFixedRatio = false, - bigMaxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, - bigMinRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, - bigFirst = true, - containerWidth = LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH, - containerHeight = LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, - alignItems = LayoutAlignment.CENTER, - bigAlignItems = LayoutAlignment.CENTER, - smallAlignItems = LayoutAlignment.CENTER, - maxWidth = Infinity, - maxHeight = Infinity, - smallMaxWidth = Infinity, - smallMaxHeight = Infinity, - bigMaxWidth = Infinity, - bigMaxHeight = Infinity, - scaleLastRow = true, - bigScaleLastRow = true - } = opts; - const availableRatio = containerHeight / containerWidth; - let offsetLeft = 0; - let offsetTop = 0; - let bigOffsetTop = 0; - let bigOffsetLeft = 0; - const bigIndices: number[] = []; - const smallIndices: number[] = []; - const topBarIndices: number[] = []; - const normalIndices: number[] = []; - let bigBoxes: LayoutBox[] = []; - let smallBoxes: LayoutBox[] = []; - let topBarBoxes: LayoutBox[] = []; - let normalBoxes: LayoutBox[] = []; - let areas: { big: LayoutArea | null; normal: LayoutArea | null; small: LayoutArea | null; topBar: LayoutArea | null } = { big: null, normal: null, small: null, topBar: null }; - - // Separate elements into categories: big, small, topbar, and normal - const bigOnes = elements.filter((element, idx) => { - if (element.big) { - bigIndices.push(idx); - return true; - } - return false; - }); - const topBarOnes = elements.filter((element, idx) => { - if (!element.big && element.topBar) { - topBarIndices.push(idx); - return true; - } - return false; - }); - const smallOnes = elements.filter((element, idx) => { - if (!element.big && !element.topBar && element.small) { - smallIndices.push(idx); - return true; - } - return false; - }); - const normalOnes = elements.filter((element, idx) => { - if (!element.big && !element.topBar && !element.small) { - normalIndices.push(idx); - return true; - } - return false; - }); - - // Handle different layout scenarios based on element types - if (bigOnes.length > 0 && (normalOnes.length > 0 || smallOnes.length > 0 || topBarOnes.length > 0)) { - // Scenario: Big elements with normal/small/topbar elements - let bigWidth; - let bigHeight; - let showBigFirst = bigFirst; - - if (availableRatio > this.getVideoRatio(bigOnes[0])) { - // We are tall, going to take up the whole width and arrange small - // guys at the bottom - bigWidth = containerWidth; - bigHeight = Math.floor(containerHeight * bigPercentage); - if (minBigPercentage > 0) { - // Find the best size for the big area - let bigDimensions; - if (!bigFixedRatio) { - bigDimensions = this.getBestDimensions( - bigMinRatio, - bigMaxRatio, - bigWidth, - bigHeight, - bigOnes.length, - bigMaxWidth, - bigMaxHeight - ); - } else { - // Use the ratio of the first video element we find to approximate - const ratio = bigOnes[0].height / bigOnes[0].width; - bigDimensions = this.getBestDimensions( - ratio, - ratio, - bigWidth, - bigHeight, - bigOnes.length, - bigMaxWidth, - bigMaxHeight - ); - } - bigHeight = Math.max( - containerHeight * minBigPercentage, - Math.min(bigHeight, bigDimensions.targetHeight * bigDimensions.targetRows) - ); - // Don't awkwardly scale the small area bigger than we need to and end up with floating - // videos in the middle - const smallDimensions = this.getBestDimensions( - minRatio, - maxRatio, - containerWidth, - containerHeight - bigHeight, - normalOnes.length + smallOnes.length + topBarOnes.length, - smallMaxWidth, - smallMaxHeight - ); - bigHeight = Math.max(bigHeight, containerHeight - smallDimensions.targetRows * smallDimensions.targetHeight); - } - offsetTop = bigHeight; - bigOffsetTop = containerHeight - offsetTop; - if (bigFirst === 'column') { - showBigFirst = false; - } else if (bigFirst === 'row') { - showBigFirst = true; - } - } else { - // We are wide, going to take up the whole height and arrange the small - // guys on the right - bigHeight = containerHeight; - bigWidth = Math.floor(containerWidth * bigPercentage); - if (minBigPercentage > 0) { - // Find the best size for the big area - let bigDimensions; - if (!bigFixedRatio) { - bigDimensions = this.getBestDimensions( - bigMinRatio, - bigMaxRatio, - bigWidth, - bigHeight, - bigOnes.length, - bigMaxWidth, - bigMaxHeight - ); - } else { - // Use the ratio of the first video element we find to approximate - const ratio = bigOnes[0].height / bigOnes[0].width; - bigDimensions = this.getBestDimensions( - ratio, - ratio, - bigWidth, - bigHeight, - bigOnes.length, - bigMaxWidth, - bigMaxHeight - ); - } - bigWidth = Math.max( - containerWidth * minBigPercentage, - Math.min(bigWidth, bigDimensions.targetWidth * bigDimensions.targetCols) - ); - // Don't awkwardly scale the small area bigger than we need to and end up with floating - // videos in the middle - const smallDimensions = this.getBestDimensions( - minRatio, - maxRatio, - containerWidth - bigWidth, - containerHeight, - normalOnes.length + smallOnes.length + topBarOnes.length, - smallMaxWidth, - smallMaxHeight - ); - bigWidth = Math.max(bigWidth, containerWidth - smallDimensions.targetCols * smallDimensions.targetWidth); - } - offsetLeft = bigWidth; - bigOffsetLeft = containerWidth - offsetLeft; - if (bigFirst === 'column') { - showBigFirst = true; - } else if (bigFirst === 'row') { - showBigFirst = false; - } - } - if (showBigFirst) { - areas.big = { - top: 0, - left: 0, - width: bigWidth, - height: bigHeight - }; - areas.normal = { - top: offsetTop, - left: offsetLeft, - width: containerWidth - offsetLeft, - height: containerHeight - offsetTop - }; - } else { - areas.big = { - left: bigOffsetLeft, - top: bigOffsetTop, - width: bigWidth, - height: bigHeight - }; - areas.normal = { - top: 0, - left: 0, - width: containerWidth - offsetLeft, - height: containerHeight - offsetTop - }; - } - } else if (bigOnes.length > 0 && normalOnes.length === 0 && smallOnes.length === 0 && topBarOnes.length === 0) { - // We only have bigOnes just center it - areas.big = { - top: 0, - left: 0, - width: containerWidth, - height: containerHeight - }; - } else if (normalOnes.length > 0 || smallOnes.length > 0 || topBarOnes.length > 0) { - // Only normal, small, and/or topbar elements - areas.normal = { - top: offsetTop, - left: offsetLeft, - width: containerWidth - offsetLeft, - height: containerHeight - offsetTop - }; - } - - if (areas.big) { - bigBoxes = this.getLayoutAux( - { - containerWidth: areas.big.width, - containerHeight: areas.big.height, - offsetLeft: areas.big.left, - offsetTop: areas.big.top, - fixedRatio: bigFixedRatio, - minRatio: bigMinRatio, - maxRatio: bigMaxRatio, - alignItems: bigAlignItems, - maxWidth: bigMaxWidth, - maxHeight: bigMaxHeight, - scaleLastRow: bigScaleLastRow - }, - bigOnes - ); - } - if (areas.normal) { - let currentTop = areas.normal.top; - let remainingHeight = areas.normal.height; - - // 1. Position TopBar Elements at the very top (header style: full width, 80px height) - if (topBarOnes.length > 0) { - const topBarHeight = 80; - const topBarWidth = Math.floor(containerWidth / topBarOnes.length); - - topBarBoxes = topBarOnes.map((element, idx) => { - return { - left: areas.normal!.left + idx * topBarWidth, - top: currentTop, - width: topBarWidth, - height: topBarHeight - }; - }); - - currentTop += topBarHeight; - remainingHeight -= topBarHeight; - } - - // 2. Position Small Elements (reduced format) - if (smallOnes.length > 0) { - const maxSmallWidthAvailable = smallMaxWidth; - const maxSmallHeightAvailable = smallMaxHeight; - - const tentativeCols = maxSmallWidthAvailable === Infinity - ? smallOnes.length - : Math.max(1, Math.floor(containerWidth / maxSmallWidthAvailable)); - const displayCols = Math.max(1, Math.min(smallOnes.length, tentativeCols)); - - const computedWidth = maxSmallWidthAvailable === Infinity - ? Math.floor(containerWidth / displayCols) - : maxSmallWidthAvailable; - const computedHeight = maxSmallHeightAvailable === Infinity ? computedWidth : maxSmallHeightAvailable; - - const rowWidth = displayCols * computedWidth; - const rowOffset = Math.floor(Math.max(0, containerWidth - rowWidth) / 2); - - smallBoxes = smallOnes.map((element, idx) => { - const col = idx % displayCols; - return { - left: areas.normal!.left + col * computedWidth + rowOffset, - top: currentTop, - width: computedWidth, - height: computedHeight - }; - }); - - currentTop += computedHeight; - remainingHeight -= computedHeight; - } - - // 3. Position Normal Elements in remaining space - if (normalOnes.length > 0) { - normalBoxes = this.getLayoutAux( - { - containerWidth: areas.normal.width, - containerHeight: Math.max(0, remainingHeight), - offsetLeft: areas.normal.left, - offsetTop: currentTop, - fixedRatio, - minRatio, - maxRatio, - alignItems: areas.big ? smallAlignItems : alignItems, - maxWidth: areas.big ? maxWidth : maxWidth, - maxHeight: areas.big ? maxHeight : maxHeight, - scaleLastRow - }, - normalOnes - ); - } - } - - const boxes: LayoutBox[] = []; - let bigBoxesIdx = 0; - let normalBoxesIdx = 0; - let smallBoxesIdx = 0; - let topBarBoxesIdx = 0; - // Rebuild the array in the right order based on element types - elements.forEach((element, idx) => { - if (bigIndices.indexOf(idx) > -1) { - boxes[idx] = bigBoxes[bigBoxesIdx]; - bigBoxesIdx += 1; - } else if (topBarIndices.indexOf(idx) > -1) { - // Element is topbar (header style: full width, limited height) - boxes[idx] = topBarBoxes[topBarBoxesIdx]; - topBarBoxesIdx += 1; - } else if (smallIndices.indexOf(idx) > -1) { - // Element is small (reduced format) - boxes[idx] = smallBoxes[smallBoxesIdx]; - smallBoxesIdx += 1; - } else { - // Element is normal - boxes[idx] = normalBoxes[normalBoxesIdx]; - normalBoxesIdx += 1; - } - }); - return { boxes, areas }; - } - - private getLayoutAux(opts: Partial, elements: ElementDimensions[]): LayoutBox[] { - const { - maxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, - minRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, - fixedRatio = false, - containerWidth = LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH, - containerHeight = LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, - offsetLeft = 0, - offsetTop = 0, - alignItems = LayoutAlignment.CENTER, - maxWidth = Infinity, - maxHeight = Infinity, - scaleLastRow = true - } = opts; - const ratios = elements.map((element) => element.height / element.width); - const count = ratios.length; - - let dimensions; - - if (!fixedRatio) { - dimensions = this.getBestDimensions(minRatio, maxRatio, containerWidth, containerHeight, count, maxWidth, maxHeight); - } else { - // Use the ratio of the first video element we find to approximate - const ratio = ratios.length > 0 ? ratios[0] : LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO; - dimensions = this.getBestDimensions(ratio, ratio, containerWidth, containerHeight, count, maxWidth, maxHeight); - } - - // Loop through each stream in the container and place it inside - let x = 0; - let y = 0; - const rows: LayoutRow[] = []; - let row: LayoutRow | undefined; - const boxes: LayoutBox[] = []; - - // Iterate through the children and create an array with a new item for each row - // and calculate the width of each row so that we know if we go over the size and need - // to adjust - for (let i = 0; i < ratios.length; i++) { - if (i % dimensions.targetCols === 0) { - // This is a new row - row = { - ratios: [], - width: 0, - height: 0 - }; - rows.push(row); - } - const ratio = ratios[i]; - if (row) { - row.ratios.push(ratio); - let targetWidth = dimensions.targetWidth; - const targetHeight = dimensions.targetHeight; - // If we're using a fixedRatio then we need to set the correct ratio for this element - if (fixedRatio) { - targetWidth = targetHeight / ratio; - } - row.width += targetWidth; - row.height = targetHeight; - } - } - // Calculate total row height adjusting if we go too wide - let totalRowHeight = 0; - let remainingShortRows = 0; - for (let i = 0; i < rows.length; i++) { - row = rows[i]; - if (row.width > containerWidth) { - // Went over on the width, need to adjust the height proportionally - row.height = Math.floor(row.height * (containerWidth / row.width)); - row.width = containerWidth; - } else if (row.width < containerWidth && row.height < maxHeight) { - remainingShortRows += 1; - } - totalRowHeight += row.height; - } - if (scaleLastRow && totalRowHeight < containerHeight && remainingShortRows > 0) { - // We can grow some of the rows, we're not taking up the whole height - let remainingHeightDiff = containerHeight - totalRowHeight; - totalRowHeight = 0; - for (let i = 0; i < rows.length; i++) { - row = rows[i]; - if (row.width < containerWidth) { - // Evenly distribute the extra height between the short rows - let extraHeight = remainingHeightDiff / remainingShortRows; - if (extraHeight / row.height > (containerWidth - row.width) / row.width) { - // We can't go that big or we'll go too wide - extraHeight = Math.floor(((containerWidth - row.width) / row.width) * row.height); - } - row.width += Math.floor((extraHeight / row.height) * row.width); - row.height += extraHeight; - remainingHeightDiff -= extraHeight; - remainingShortRows -= 1; - } - totalRowHeight += row.height; - } - } - // vertical centering - switch (alignItems) { - case 'start': - y = 0; - break; - case 'end': - y = containerHeight - totalRowHeight; - break; - case 'center': - default: - y = (containerHeight - totalRowHeight) / 2; - break; - } - // Iterate through each row and place each child - for (let i = 0; i < rows.length; i++) { - row = rows[i]; - let rowMarginLeft; - switch (alignItems) { - case 'start': - rowMarginLeft = 0; - break; - case 'end': - rowMarginLeft = containerWidth - row.width; - break; - case 'center': - default: - rowMarginLeft = (containerWidth - row.width) / 2; - break; - } - x = rowMarginLeft; - let targetHeight = row.height; - for (let j = 0; j < row.ratios.length; j++) { - const ratio = row.ratios[j]; - - let targetWidth = dimensions.targetWidth; - targetHeight = row.height; - // If we're using a fixedRatio then we need to set the correct ratio for this element - if (fixedRatio) { - targetWidth = Math.floor(targetHeight / ratio); - } else if (targetHeight / targetWidth !== dimensions.targetHeight / dimensions.targetWidth) { - // We grew this row, we need to adjust the width to account for the increase in height - targetWidth = Math.floor((dimensions.targetWidth / dimensions.targetHeight) * targetHeight); - } - - boxes.push({ - left: x + offsetLeft, - top: y + offsetTop, - width: targetWidth, - height: targetHeight - }); - x += targetWidth; - } - y += targetHeight; - } - return boxes; - } -} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-calculator.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-calculator.model.ts new file mode 100644 index 000000000..ba8d76f21 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-calculator.model.ts @@ -0,0 +1,667 @@ +import { LayoutDimensionsCache } from './layout-dimensions-cache.model'; +import { + BestDimensions, + CategorizedElements, + ElementDimensions, + ExtendedLayoutOptions, + LAYOUT_CONSTANTS, + LayoutAlignment, + LayoutBox, + LayoutCalculationResult, + LayoutRow +} from './layout-types.model'; + +/** + * Pure calculation logic for layout positioning. + * Contains all mathematical algorithms for element positioning without DOM manipulation. + * + * @internal + */ +export class LayoutCalculator { + constructor(private dimensionsCache: LayoutDimensionsCache) {} + + /** + * Calculate complete layout including boxes and areas + * @param opts Extended layout options with container dimensions + * @param elements Array of element dimensions + * @returns Layout calculation result with boxes and areas + */ + calculateLayout(opts: ExtendedLayoutOptions, elements: ElementDimensions[]): LayoutCalculationResult { + const { + maxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, + minRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, + fixedRatio = false, + bigPercentage = LAYOUT_CONSTANTS.DEFAULT_BIG_PERCENTAGE, + minBigPercentage = 0, + bigFixedRatio = false, + bigMaxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, + bigMinRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, + bigFirst = true, + containerWidth = LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH, + containerHeight = LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, + alignItems = LayoutAlignment.CENTER, + bigAlignItems = LayoutAlignment.CENTER, + smallAlignItems = LayoutAlignment.CENTER, + maxWidth = Infinity, + maxHeight = Infinity, + smallMaxWidth = Infinity, + smallMaxHeight = Infinity, + bigMaxWidth = Infinity, + bigMaxHeight = Infinity, + scaleLastRow = true, + bigScaleLastRow = true + } = opts; + + const availableRatio = containerHeight / containerWidth; + let offsetLeft = 0; + let offsetTop = 0; + let bigOffsetTop = 0; + let bigOffsetLeft = 0; + + // Categorize elements + const categorized = this.categorizeElements(elements); + const { bigOnes, normalOnes, smallOnes, topBarOnes } = categorized; + + let bigBoxes: LayoutBox[] = []; + let smallBoxes: LayoutBox[] = []; + let topBarBoxes: LayoutBox[] = []; + let normalBoxes: LayoutBox[] = []; + let areas: LayoutCalculationResult['areas'] = { big: null, normal: null, small: null, topBar: null }; + + // Handle different layout scenarios based on element types + if (bigOnes.length > 0 && (normalOnes.length > 0 || smallOnes.length > 0 || topBarOnes.length > 0)) { + // Scenario: Big elements with normal/small/topbar elements + let bigWidth; + let bigHeight; + let showBigFirst = bigFirst; + + if (availableRatio > this.getVideoRatio(bigOnes[0])) { + // We are tall, going to take up the whole width and arrange small guys at the bottom + bigWidth = containerWidth; + bigHeight = Math.floor(containerHeight * bigPercentage); + + if (minBigPercentage > 0) { + // Find the best size for the big area + let bigDimensions; + if (!bigFixedRatio) { + bigDimensions = this.getBestDimensions( + bigMinRatio, + bigMaxRatio, + bigWidth, + bigHeight, + bigOnes.length, + bigMaxWidth, + bigMaxHeight + ); + } else { + // Use the ratio of the first video element we find to approximate + const ratio = bigOnes[0].height / bigOnes[0].width; + bigDimensions = this.getBestDimensions(ratio, ratio, bigWidth, bigHeight, bigOnes.length, bigMaxWidth, bigMaxHeight); + } + + bigHeight = Math.max( + containerHeight * minBigPercentage, + Math.min(bigHeight, bigDimensions.targetHeight * bigDimensions.targetRows) + ); + + // Don't awkwardly scale the small area bigger than we need to and end up with floating videos in the middle + const smallDimensions = this.getBestDimensions( + minRatio, + maxRatio, + containerWidth, + containerHeight - bigHeight, + normalOnes.length + smallOnes.length + topBarOnes.length, + smallMaxWidth, + smallMaxHeight + ); + bigHeight = Math.max(bigHeight, containerHeight - smallDimensions.targetRows * smallDimensions.targetHeight); + } + + offsetTop = bigHeight; + bigOffsetTop = containerHeight - offsetTop; + + if (bigFirst === 'column') { + showBigFirst = false; + } else if (bigFirst === 'row') { + showBigFirst = true; + } + } else { + // We are wide, going to take up the whole height and arrange the small guys on the right + bigHeight = containerHeight; + bigWidth = Math.floor(containerWidth * bigPercentage); + + if (minBigPercentage > 0) { + // Find the best size for the big area + let bigDimensions; + if (!bigFixedRatio) { + bigDimensions = this.getBestDimensions( + bigMinRatio, + bigMaxRatio, + bigWidth, + bigHeight, + bigOnes.length, + bigMaxWidth, + bigMaxHeight + ); + } else { + // Use the ratio of the first video element we find to approximate + const ratio = bigOnes[0].height / bigOnes[0].width; + bigDimensions = this.getBestDimensions(ratio, ratio, bigWidth, bigHeight, bigOnes.length, bigMaxWidth, bigMaxHeight); + } + + bigWidth = Math.max( + containerWidth * minBigPercentage, + Math.min(bigWidth, bigDimensions.targetWidth * bigDimensions.targetCols) + ); + + // Don't awkwardly scale the small area bigger than we need to and end up with floating videos in the middle + const smallDimensions = this.getBestDimensions( + minRatio, + maxRatio, + containerWidth - bigWidth, + containerHeight, + normalOnes.length + smallOnes.length + topBarOnes.length, + smallMaxWidth, + smallMaxHeight + ); + bigWidth = Math.max(bigWidth, containerWidth - smallDimensions.targetCols * smallDimensions.targetWidth); + } + + offsetLeft = bigWidth; + bigOffsetLeft = containerWidth - offsetLeft; + + if (bigFirst === 'column') { + showBigFirst = true; + } else if (bigFirst === 'row') { + showBigFirst = false; + } + } + + if (showBigFirst) { + areas.big = { top: 0, left: 0, width: bigWidth, height: bigHeight }; + areas.normal = { top: offsetTop, left: offsetLeft, width: containerWidth - offsetLeft, height: containerHeight - offsetTop }; + } else { + areas.big = { left: bigOffsetLeft, top: bigOffsetTop, width: bigWidth, height: bigHeight }; + areas.normal = { top: 0, left: 0, width: containerWidth - offsetLeft, height: containerHeight - offsetTop }; + } + } else if (bigOnes.length > 0 && normalOnes.length === 0 && smallOnes.length === 0 && topBarOnes.length === 0) { + // We only have bigOnes just center it + areas.big = { top: 0, left: 0, width: containerWidth, height: containerHeight }; + } else if (normalOnes.length > 0 || smallOnes.length > 0 || topBarOnes.length > 0) { + // Only normal, small, and/or topbar elements + areas.normal = { top: offsetTop, left: offsetLeft, width: containerWidth - offsetLeft, height: containerHeight - offsetTop }; + } + + // Calculate boxes for each area + if (areas.big) { + bigBoxes = this.calculateBoxesForArea( + { + containerWidth: areas.big.width, + containerHeight: areas.big.height, + offsetLeft: areas.big.left, + offsetTop: areas.big.top, + fixedRatio: bigFixedRatio, + minRatio: bigMinRatio, + maxRatio: bigMaxRatio, + alignItems: bigAlignItems, + maxWidth: bigMaxWidth, + maxHeight: bigMaxHeight, + scaleLastRow: bigScaleLastRow + }, + bigOnes + ); + } + + if (areas.normal) { + let currentTop = areas.normal.top; + let remainingHeight = areas.normal.height; + + // 1. Position TopBar Elements at the very top (header style: full width, 80px height) + if (topBarOnes.length > 0) { + const topBarHeight = 80; + const topBarWidth = Math.floor(containerWidth / topBarOnes.length); + + topBarBoxes = topBarOnes.map((element, idx) => { + return { + left: areas.normal!.left + idx * topBarWidth, + top: currentTop, + width: topBarWidth, + height: topBarHeight + }; + }); + + currentTop += topBarHeight; + remainingHeight -= topBarHeight; + } + + // 2. Position Small Elements (reduced format) + if (smallOnes.length > 0) { + const maxSmallWidthAvailable = smallMaxWidth; + const maxSmallHeightAvailable = smallMaxHeight; + + const tentativeCols = maxSmallWidthAvailable === Infinity + ? smallOnes.length + : Math.max(1, Math.floor(containerWidth / maxSmallWidthAvailable)); + const displayCols = Math.max(1, Math.min(smallOnes.length, tentativeCols)); + + const computedWidth = maxSmallWidthAvailable === Infinity + ? Math.floor(containerWidth / displayCols) + : maxSmallWidthAvailable; + const computedHeight = maxSmallHeightAvailable === Infinity ? computedWidth : maxSmallHeightAvailable; + + const rowWidth = displayCols * computedWidth; + const rowOffset = Math.floor(Math.max(0, containerWidth - rowWidth) / 2); + + smallBoxes = smallOnes.map((element, idx) => { + const col = idx % displayCols; + return { + left: areas.normal!.left + col * computedWidth + rowOffset, + top: currentTop, + width: computedWidth, + height: computedHeight + }; + }); + + currentTop += computedHeight; + remainingHeight -= computedHeight; + } + + // 3. Position Normal Elements in remaining space + if (normalOnes.length > 0) { + normalBoxes = this.calculateBoxesForArea( + { + containerWidth: areas.normal.width, + containerHeight: Math.max(0, remainingHeight), + offsetLeft: areas.normal.left, + offsetTop: currentTop, + fixedRatio, + minRatio, + maxRatio, + alignItems: areas.big ? smallAlignItems : alignItems, + maxWidth: areas.big ? maxWidth : maxWidth, + maxHeight: areas.big ? maxHeight : maxHeight, + scaleLastRow + }, + normalOnes + ); + } + } + + // Rebuild the array in the right order based on element types + const boxes = this.reconstructBoxesInOrder( + elements, + categorized, + bigBoxes, + normalBoxes, + smallBoxes, + topBarBoxes + ); + + return { boxes, areas }; + } + + /** + * Calculate best dimensions for a set of elements + * @param minRatio Minimum aspect ratio + * @param maxRatio Maximum aspect ratio + * @param width Available width + * @param height Available height + * @param count Number of elements + * @param maxWidth Maximum element width + * @param maxHeight Maximum element height + * @returns Best dimensions calculation result + */ + getBestDimensions( + minRatio: number, + maxRatio: number, + width: number, + height: number, + count: number, + maxWidth: number, + maxHeight: number + ): BestDimensions { + // Cache key for memoization + const cacheKey = LayoutDimensionsCache.generateKey(minRatio, maxRatio, width, height, count, maxWidth, maxHeight); + const cached = this.dimensionsCache.get(cacheKey); + if (cached) { + return cached; + } + + let maxArea: number | undefined; + let targetCols = 1; + let targetRows = 1; + let targetHeight = 0; + let targetWidth = 0; + + // Iterate through every possible combination of rows and columns + // and see which one has the least amount of whitespace + for (let i = 1; i <= count; i++) { + const cols = i; + const rows = Math.ceil(count / cols); + + // Try taking up the whole height and width + let tHeight = Math.floor(height / rows); + let tWidth = Math.floor(width / cols); + + let tRatio = tHeight / tWidth; + if (tRatio > maxRatio) { + // We went over decrease the height + tRatio = maxRatio; + tHeight = tWidth * tRatio; + } else if (tRatio < minRatio) { + // We went under decrease the width + tRatio = minRatio; + tWidth = tHeight / tRatio; + } + + tWidth = Math.min(maxWidth, tWidth); + tHeight = Math.min(maxHeight, tHeight); + + const area = tWidth * tHeight * count; + + // If this width and height takes up the most space then we're going with that + if (maxArea === undefined || area >= maxArea) { + if (!(area === maxArea && count % (cols * rows) > count % (targetRows * targetCols))) { + // Favour even numbers of participants in each row, eg. 2 on each row + // instead of 3 in one row and then 1 on the next + maxArea = area; + targetHeight = tHeight; + targetWidth = tWidth; + targetCols = cols; + targetRows = rows; + } + } + } + + const result: BestDimensions = { + maxArea: maxArea || 0, + targetCols: targetCols, + targetRows: targetRows, + targetHeight: targetHeight, + targetWidth: targetWidth, + ratio: targetHeight / targetWidth || 0 + }; + + // Cache the result for future use + this.dimensionsCache.set(cacheKey, result); + + return result; + } + + /** + * Calculate boxes for a specific area + * @param opts Area-specific layout options + * @param elements Elements to position in this area + * @returns Array of layout boxes + */ + private calculateBoxesForArea( + opts: Partial, + elements: ElementDimensions[] + ): LayoutBox[] { + const { + maxRatio = LAYOUT_CONSTANTS.DEFAULT_MAX_RATIO, + minRatio = LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO, + fixedRatio = false, + containerWidth = LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH, + containerHeight = LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, + offsetLeft = 0, + offsetTop = 0, + alignItems = LayoutAlignment.CENTER, + maxWidth = Infinity, + maxHeight = Infinity, + scaleLastRow = true + } = opts; + + const ratios = elements.map((element) => element.height / element.width); + const count = ratios.length; + + let dimensions; + + if (!fixedRatio) { + dimensions = this.getBestDimensions(minRatio, maxRatio, containerWidth, containerHeight, count, maxWidth, maxHeight); + } else { + // Use the ratio of the first video element we find to approximate + const ratio = ratios.length > 0 ? ratios[0] : LAYOUT_CONSTANTS.DEFAULT_MIN_RATIO; + dimensions = this.getBestDimensions(ratio, ratio, containerWidth, containerHeight, count, maxWidth, maxHeight); + } + + // Loop through each stream in the container and place it inside + let x = 0; + let y = 0; + const rows: LayoutRow[] = []; + let row: LayoutRow | undefined; + const boxes: LayoutBox[] = []; + + // Iterate through the children and create an array with a new item for each row + // and calculate the width of each row so that we know if we go over the size and need to adjust + for (let i = 0; i < ratios.length; i++) { + if (i % dimensions.targetCols === 0) { + // This is a new row + row = { ratios: [], width: 0, height: 0 }; + rows.push(row); + } + const ratio = ratios[i]; + if (row) { + row.ratios.push(ratio); + let targetWidth = dimensions.targetWidth; + const targetHeight = dimensions.targetHeight; + // If we're using a fixedRatio then we need to set the correct ratio for this element + if (fixedRatio) { + targetWidth = targetHeight / ratio; + } + row.width += targetWidth; + row.height = targetHeight; + } + } + + // Calculate total row height adjusting if we go too wide + let totalRowHeight = 0; + let remainingShortRows = 0; + for (let i = 0; i < rows.length; i++) { + row = rows[i]; + if (row.width > containerWidth) { + // Went over on the width, need to adjust the height proportionally + row.height = Math.floor(row.height * (containerWidth / row.width)); + row.width = containerWidth; + } else if (row.width < containerWidth && row.height < maxHeight) { + remainingShortRows += 1; + } + totalRowHeight += row.height; + } + + if (scaleLastRow && totalRowHeight < containerHeight && remainingShortRows > 0) { + // We can grow some of the rows, we're not taking up the whole height + let remainingHeightDiff = containerHeight - totalRowHeight; + totalRowHeight = 0; + for (let i = 0; i < rows.length; i++) { + row = rows[i]; + if (row.width < containerWidth) { + // Evenly distribute the extra height between the short rows + let extraHeight = remainingHeightDiff / remainingShortRows; + if (extraHeight / row.height > (containerWidth - row.width) / row.width) { + // We can't go that big or we'll go too wide + extraHeight = Math.floor(((containerWidth - row.width) / row.width) * row.height); + } + row.width += Math.floor((extraHeight / row.height) * row.width); + row.height += extraHeight; + remainingHeightDiff -= extraHeight; + remainingShortRows -= 1; + } + totalRowHeight += row.height; + } + } + + // vertical centering + switch (alignItems) { + case LayoutAlignment.START: + y = 0; + break; + case LayoutAlignment.END: + y = containerHeight - totalRowHeight; + break; + case LayoutAlignment.CENTER: + default: + y = (containerHeight - totalRowHeight) / 2; + break; + } + + // Iterate through each row and place each child + for (let i = 0; i < rows.length; i++) { + row = rows[i]; + let rowMarginLeft; + switch (alignItems) { + case LayoutAlignment.START: + rowMarginLeft = 0; + break; + case LayoutAlignment.END: + rowMarginLeft = containerWidth - row.width; + break; + case LayoutAlignment.CENTER: + default: + rowMarginLeft = (containerWidth - row.width) / 2; + break; + } + x = rowMarginLeft; + let targetHeight = row.height; + for (let j = 0; j < row.ratios.length; j++) { + const ratio = row.ratios[j]; + + let targetWidth = dimensions.targetWidth; + targetHeight = row.height; + // If we're using a fixedRatio then we need to set the correct ratio for this element + if (fixedRatio) { + targetWidth = Math.floor(targetHeight / ratio); + } else if (targetHeight / targetWidth !== dimensions.targetHeight / dimensions.targetWidth) { + // We grew this row, we need to adjust the width to account for the increase in height + targetWidth = Math.floor((dimensions.targetWidth / dimensions.targetHeight) * targetHeight); + } + + boxes.push({ + left: x + offsetLeft, + top: y + offsetTop, + width: targetWidth, + height: targetHeight + }); + x += targetWidth; + } + y += targetHeight; + } + return boxes; + } + + /** + * Categorize elements into big, normal, small, and topBar + * @param elements Elements to categorize + * @returns Categorized elements with their indices + */ + private categorizeElements(elements: ElementDimensions[]): CategorizedElements & { + bigOnes: ElementDimensions[]; + normalOnes: ElementDimensions[]; + smallOnes: ElementDimensions[]; + topBarOnes: ElementDimensions[]; + } { + const bigIndices: number[] = []; + const smallIndices: number[] = []; + const topBarIndices: number[] = []; + const normalIndices: number[] = []; + + const bigOnes = elements.filter((element, idx) => { + if (element.big) { + bigIndices.push(idx); + return true; + } + return false; + }); + + const topBarOnes = elements.filter((element, idx) => { + if (!element.big && element.topBar) { + topBarIndices.push(idx); + return true; + } + return false; + }); + + const smallOnes = elements.filter((element, idx) => { + if (!element.big && !element.topBar && element.small) { + smallIndices.push(idx); + return true; + } + return false; + }); + + const normalOnes = elements.filter((element, idx) => { + if (!element.big && !element.topBar && !element.small) { + normalIndices.push(idx); + return true; + } + return false; + }); + + return { + big: bigOnes, + normal: normalOnes, + small: smallOnes, + topBar: topBarOnes, + bigOnes, + normalOnes, + smallOnes, + topBarOnes, + bigIndices, + normalIndices, + smallIndices, + topBarIndices + }; + } + + /** + * Reconstruct boxes in original element order + * @param elements Original elements + * @param categorized Categorized elements + * @param bigBoxes Boxes for big elements + * @param normalBoxes Boxes for normal elements + * @param smallBoxes Boxes for small elements + * @param topBarBoxes Boxes for topBar elements + * @returns Boxes in original order + */ + private reconstructBoxesInOrder( + elements: ElementDimensions[], + categorized: CategorizedElements, + bigBoxes: LayoutBox[], + normalBoxes: LayoutBox[], + smallBoxes: LayoutBox[], + topBarBoxes: LayoutBox[] + ): LayoutBox[] { + const boxes: LayoutBox[] = []; + let bigBoxesIdx = 0; + let normalBoxesIdx = 0; + let smallBoxesIdx = 0; + let topBarBoxesIdx = 0; + + elements.forEach((element, idx) => { + if (categorized.bigIndices.indexOf(idx) > -1) { + boxes[idx] = bigBoxes[bigBoxesIdx]; + bigBoxesIdx += 1; + } else if (categorized.topBarIndices.indexOf(idx) > -1) { + boxes[idx] = topBarBoxes[topBarBoxesIdx]; + topBarBoxesIdx += 1; + } else if (categorized.smallIndices.indexOf(idx) > -1) { + boxes[idx] = smallBoxes[smallBoxesIdx]; + smallBoxesIdx += 1; + } else { + boxes[idx] = normalBoxes[normalBoxesIdx]; + normalBoxesIdx += 1; + } + }); + + return boxes; + } + + /** + * Get video aspect ratio + * @param element Element dimensions + * @returns Aspect ratio (height/width) + */ + private getVideoRatio(element: ElementDimensions): number { + return element.height / element.width; + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-dimensions-cache.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-dimensions-cache.model.ts new file mode 100644 index 000000000..9e4552532 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-dimensions-cache.model.ts @@ -0,0 +1,76 @@ +import { BestDimensions } from './layout-types.model'; + +/** + * Manages caching of dimension calculations for layout optimization. + * Uses memoization to avoid recalculating the same layout dimensions. + * + * @internal + */ +export class LayoutDimensionsCache { + private cache = new Map(); + + /** + * Retrieves cached dimension calculation if available + * @param key Cache key generated from calculation parameters + * @returns Cached dimensions or undefined if not found + */ + get(key: string): BestDimensions | undefined { + return this.cache.get(key); + } + + /** + * Stores dimension calculation result in cache + * @param key Cache key generated from calculation parameters + * @param value Calculated best dimensions + */ + set(key: string, value: BestDimensions): void { + this.cache.set(key, value); + } + + /** + * Clears all cached dimensions to free memory + */ + clear(): void { + this.cache.clear(); + } + + /** + * Gets the current cache size + * @returns Number of cached entries + */ + size(): number { + return this.cache.size; + } + + /** + * Checks if a key exists in the cache + * @param key Cache key to check + * @returns True if key exists + */ + has(key: string): boolean { + return this.cache.has(key); + } + + /** + * Generates a cache key from calculation parameters + * @param minRatio Minimum aspect ratio + * @param maxRatio Maximum aspect ratio + * @param width Container width + * @param height Container height + * @param count Number of elements + * @param maxWidth Maximum element width + * @param maxHeight Maximum element height + * @returns Cache key string + */ + static generateKey( + minRatio: number, + maxRatio: number, + width: number, + height: number, + count: number, + maxWidth: number, + maxHeight: number + ): string { + return `${minRatio}_${maxRatio}_${width}_${height}_${count}_${maxWidth}_${maxHeight}`; + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-renderer.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-renderer.model.ts new file mode 100644 index 000000000..fb883db01 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-renderer.model.ts @@ -0,0 +1,196 @@ +import { LAYOUT_CONSTANTS, LayoutBox } from './layout-types.model'; + +/** + * Position information for DOM element + */ +interface ElementPosition { + left: string; + top: string; + width: string; + height: string; + [key: string]: string; // Allow index signature for dynamic access +} + +/** + * Handles DOM manipulation and rendering for layout elements. + * Manages positioning, animations, and visual updates without calculation logic. + * + * @internal + */ +export class LayoutRenderer { + + /** + * Render layout boxes to DOM elements + * @param container Parent container element + * @param boxes Calculated layout boxes + * @param elements DOM elements to position + * @param animate Whether to animate transitions + */ + renderLayout(container: HTMLElement, boxes: LayoutBox[], elements: HTMLElement[], animate: boolean): void { + boxes.forEach((box, idx) => { + const elem = elements[idx]; + if (!elem) return; + + // Set position:absolute for proper layout positioning + this.getCssProperty(elem, 'position', 'absolute'); + + const actualDimensions = this.calculateActualDimensions(elem, box); + this.positionElement(elem, box.left, box.top, actualDimensions.width, actualDimensions.height, animate); + }); + } + + /** + * Calculate actual element dimensions accounting for margins, padding, and borders + * @param elem DOM element + * @param box Layout box dimensions + * @returns Actual width and height + */ + private calculateActualDimensions(elem: HTMLElement, box: LayoutBox): { width: number; height: number } { + const actualWidth = + box.width - + this.getCSSNumber(elem, 'margin-left') - + this.getCSSNumber(elem, 'margin-right') - + (this.getCssProperty(elem, 'box-sizing') !== 'border-box' + ? this.getCSSNumber(elem, 'padding-left') + + this.getCSSNumber(elem, 'padding-right') + + this.getCSSNumber(elem, 'border-left') + + this.getCSSNumber(elem, 'border-right') + : 0); + + const actualHeight = + box.height - + this.getCSSNumber(elem, 'margin-top') - + this.getCSSNumber(elem, 'margin-bottom') - + (this.getCssProperty(elem, 'box-sizing') !== 'border-box' + ? this.getCSSNumber(elem, 'padding-top') + + this.getCSSNumber(elem, 'padding-bottom') + + this.getCSSNumber(elem, 'border-top') + + this.getCSSNumber(elem, 'border-bottom') + : 0); + + return { width: actualWidth, height: actualHeight }; + } + + /** + * Position element at specified coordinates with optional animation + * @param elem Video or HTML element to position + * @param x Left position + * @param y Top position + * @param width Element width + * @param height Element height + * @param animate Whether to animate the transition + */ + private positionElement( + elem: HTMLVideoElement | HTMLElement, + x: number, + y: number, + width: number, + height: number, + animate: boolean + ): void { + const targetPosition: ElementPosition = { + left: `${x}px`, + top: `${y}px`, + width: `${width}px`, + height: `${height}px` + }; + + this.fixAspectRatio(elem, width); + + if (animate) { + setTimeout(() => { + // Animation added in CSS transition: all .1s linear; + this.animateElement(elem, targetPosition); + this.fixAspectRatio(elem, width); + }, 10); + } else { + this.setElementPosition(elem, targetPosition); + if (!elem.classList.contains('layout')) { + elem.classList.add('layout'); + } + } + + this.fixAspectRatio(elem, width); + } + + /** + * Set element position without animation + * @param elem Element to position + * @param targetPosition Target position object + */ + private setElementPosition(elem: HTMLVideoElement | HTMLElement, targetPosition: ElementPosition): void { + Object.keys(targetPosition).forEach((key) => { + (elem.style as any)[key] = targetPosition[key]; + }); + } + + /** + * Animate element to target position + * @param elem Element to animate + * @param targetPosition Target position object + */ + private animateElement(elem: HTMLVideoElement | HTMLElement, targetPosition: ElementPosition): void { + elem.style.transition = `all ${LAYOUT_CONSTANTS.ANIMATION_DURATION} ${LAYOUT_CONSTANTS.ANIMATION_EASING}`; + this.setElementPosition(elem, targetPosition); + } + + /** + * Fix aspect ratio for video elements + * @param elem Element to fix + * @param width Target width + */ + private fixAspectRatio(elem: HTMLVideoElement | HTMLElement, width: number): void { + const sub = elem.querySelector('.OV_root') as HTMLVideoElement; + if (sub) { + // If this is the parent of a subscriber or publisher, then we need + // to force the mutation observer on the publisher or subscriber to + // trigger to get it to fix its layout + const oldWidth = sub.style.width; + sub.style.width = `${width}px`; + sub.style.width = oldWidth || ''; + } + } + + /** + * Get CSS property value or set it + * @param el Element to query/modify + * @param propertyName Property name or object of properties + * @param value Optional value to set + * @returns Property value if getting, void if setting + */ + private getCssProperty( + el: HTMLVideoElement | HTMLElement, + propertyName: any, + value?: string + ): void | string { + if (value !== undefined) { + // Set one CSS property + el.style[propertyName] = value; + } else if (typeof propertyName === 'object') { + // Set several CSS properties at once + Object.keys(propertyName).forEach((key) => { + this.getCssProperty(el, key, propertyName[key]); + }); + } else { + // Get the CSS property + const computedStyle = window.getComputedStyle(el); + let currentValue = computedStyle.getPropertyValue(propertyName); + + if (currentValue === '') { + currentValue = el.style[propertyName]; + } + return currentValue; + } + } + + /** + * Get CSS property as number + * @param elem Element to query + * @param prop Property name + * @returns Numeric value or 0 if not found + */ + private getCSSNumber(elem: HTMLElement, prop: string): number { + const cssStr = this.getCssProperty(elem, prop); + return cssStr ? parseInt(cssStr, 10) : 0; + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-types.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-types.model.ts new file mode 100644 index 000000000..bf0da1986 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout-types.model.ts @@ -0,0 +1,192 @@ +/** + * @internal + */ +export enum LayoutClass { + ROOT_ELEMENT = 'OV_root', + BIG_ELEMENT = 'OV_big', + SMALL_ELEMENT = 'OV_small', + TOP_BAR_ELEMENT = 'OV_top-bar', + IGNORED_ELEMENT = 'OV_ignored', + MINIMIZED_ELEMENT = 'OV_minimized', + SIDENAV_CONTAINER = 'sidenav-container', + NO_SIZE_ELEMENT = 'no-size', + CLASS_NAME = 'layout' +} + +/** + * @internal + */ +export enum SidenavMode { + OVER = 'over', + SIDE = 'side' +} + +/** + * @internal + */ +export enum LayoutAlignment { + START = 'start', + CENTER = 'center', + END = 'end' +} + +/** + * Layout position options for big elements + */ +export type BigFirstOption = boolean | 'column' | 'row'; + +/** + * Element dimensions interface + */ +export interface ElementDimensions { + height: number; + width: number; + big?: boolean; + small?: boolean; + topBar?: boolean; +} + +/** + * Layout area definition + */ +export interface LayoutArea { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Layout box positioning + */ +export interface LayoutBox extends LayoutArea {} + +/** + * Row structure for layout calculations + */ +export interface LayoutRow { + ratios: number[]; + width: number; + height: number; +} + +/** + * Best dimensions calculation result + */ +export interface BestDimensions { + maxArea: number; + targetCols: number; + targetRows: number; + targetHeight: number; + targetWidth: number; + ratio: number; +} + +/** + * Extended layout options with container dimensions + */ +export interface ExtendedLayoutOptions extends OpenViduLayoutOptions { + containerWidth: number; + containerHeight: number; +} + +/** + * Layout calculation result containing positioned boxes and allocated areas + */ +export interface LayoutCalculationResult { + boxes: LayoutBox[]; + areas: LayoutAreas; +} + +/** + * Layout areas for different element categories + */ +export interface LayoutAreas { + big: LayoutArea | null; + normal: LayoutArea | null; + small: LayoutArea | null; + topBar: LayoutArea | null; +} + +/** + * Categorized elements by type + * @internal + */ +export interface CategorizedElements { + big: ElementDimensions[]; + normal: ElementDimensions[]; + small: ElementDimensions[]; + topBar: ElementDimensions[]; + bigIndices: number[]; + normalIndices: number[]; + smallIndices: number[]; + topBarIndices: number[]; +} + +/** + * Layout configuration constants + */ +export const LAYOUT_CONSTANTS = { + DEFAULT_VIDEO_WIDTH: 640, + DEFAULT_VIDEO_HEIGHT: 480, + DEFAULT_MAX_RATIO: 3 / 2, + DEFAULT_MIN_RATIO: 9 / 16, + DEFAULT_BIG_PERCENTAGE: 0.8, + UPDATE_TIMEOUT: 50, + ANIMATION_DURATION: '0.1s', + ANIMATION_EASING: 'linear' +} as const; + +/** + * @internal + */ +export interface OpenViduLayoutOptions { + /** The narrowest ratio that will be used (2x3 by default) */ + maxRatio: number; + /** The widest ratio that will be used (16x9 by default) */ + minRatio: number; + /** If true, aspect ratio is maintained and minRatio/maxRatio are ignored */ + fixedRatio: boolean; + /** Whether to animate transitions */ + animate: boolean; + /** Class for elements that should be sized bigger */ + bigClass: string; + /** Class for elements that should be sized smaller */ + smallClass: string; + /** Class for elements that should be ignored */ + ignoredClass: string; + /** Maximum percentage of space big elements should take up */ + bigPercentage: number; + /** Minimum percentage for big space to scale down whitespace */ + minBigPercentage: number; + /** Fixed ratio for big elements */ + bigFixedRatio: boolean; + /** Narrowest ratio for big elements */ + bigMaxRatio: number; + /** Widest ratio for big elements */ + bigMinRatio: number; + /** Position preference for big elements */ + bigFirst: BigFirstOption; + /** Alignment for all elements */ + alignItems: LayoutAlignment; + /** Alignment for big elements */ + bigAlignItems: LayoutAlignment; + /** Alignment for small elements */ + smallAlignItems: LayoutAlignment; + /** Maximum width of elements */ + maxWidth: number; + /** Maximum height of elements */ + maxHeight: number; + /** Maximum width for small elements */ + smallMaxWidth: number; + /** Maximum height for small elements */ + smallMaxHeight: number; + /** Maximum width for big elements */ + bigMaxWidth: number; + /** Maximum height for big elements */ + bigMaxHeight: number; + /** Scale up elements in last row if fewer elements */ + scaleLastRow: boolean; + /** Scale up big elements in last row */ + bigScaleLastRow: boolean; +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout.model.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout.model.ts new file mode 100644 index 000000000..c2627d233 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/models/layout/layout.model.ts @@ -0,0 +1,222 @@ +// Re-export all public types and constants for backward compatibility +export { + BestDimensions, + BigFirstOption, + ElementDimensions, + ExtendedLayoutOptions, + LAYOUT_CONSTANTS, + LayoutAlignment, + LayoutArea, + LayoutBox, + LayoutClass, + LayoutRow, + OpenViduLayoutOptions, + SidenavMode +} from './layout-types.model'; + +import { LayoutCalculator } from './layout-calculator.model'; +import { LayoutDimensionsCache } from './layout-dimensions-cache.model'; +import { LayoutRenderer } from './layout-renderer.model'; +import { ElementDimensions, ExtendedLayoutOptions, LAYOUT_CONSTANTS, LayoutClass, OpenViduLayoutOptions } from './layout-types.model'; + +/** + * OpenViduLayout orchestrates layout calculation and rendering. + * Maintains backward compatibility with existing API while delegating to specialized classes. + * + * @internal + */ +export class OpenViduLayout { + private layoutContainer!: HTMLElement; + private opts!: OpenViduLayoutOptions; + + // Specialized components + private dimensionsCache: LayoutDimensionsCache; + private calculator: LayoutCalculator; + private renderer: LayoutRenderer; + + constructor() { + this.dimensionsCache = new LayoutDimensionsCache(); + this.calculator = new LayoutCalculator(this.dimensionsCache); + this.renderer = new LayoutRenderer(); + } + + /** + * Update the layout container + * module export layout + */ + updateLayout(container: HTMLElement, opts: OpenViduLayoutOptions) { + setTimeout(() => { + this.layoutContainer = container; + this.opts = opts; + + if (this.getCssProperty(this.layoutContainer, 'display') === 'none') { + return; + } + + let id = this.layoutContainer.id; + if (!id) { + id = 'OV_' + this.cheapUUID(); + this.layoutContainer.id = id; + } + + const extendedOpts: ExtendedLayoutOptions = { + ...opts, + containerHeight: + this.getHeight(this.layoutContainer) - + this.getCSSNumber(this.layoutContainer, 'border-top') - + this.getCSSNumber(this.layoutContainer, 'border-bottom'), + containerWidth: + this.getWidth(this.layoutContainer) - + this.getCSSNumber(this.layoutContainer, 'border-left') - + this.getCSSNumber(this.layoutContainer, 'border-right') + }; + + const selector = `#${id}>*:not(.${LayoutClass.IGNORED_ELEMENT}):not(.${LayoutClass.MINIMIZED_ELEMENT})`; + const children = Array.prototype.filter.call(this.layoutContainer.querySelectorAll(selector), () => this.filterDisplayNone); + + const elements = children.map((element: HTMLElement) => { + const res = this.getChildDims(element); + res.big = element.classList.contains(this.opts.bigClass); + res.small = element.classList.contains(LayoutClass.SMALL_ELEMENT); + res.topBar = element.classList.contains(LayoutClass.TOP_BAR_ELEMENT); + return res; + }); + + // Delegate calculation to LayoutCalculator + const layout = this.calculator.calculateLayout(extendedOpts, elements); + + // Delegate rendering to LayoutRenderer + this.renderer.renderLayout(this.layoutContainer, layout.boxes, children, this.opts.animate); + }, LAYOUT_CONSTANTS.UPDATE_TIMEOUT); + } + + /** + * Initialize the layout inside of the container with the options required + * @param container + * @param opts + */ + initLayoutContainer(container: HTMLElement, opts: OpenViduLayoutOptions) { + this.opts = opts; + this.layoutContainer = container; + this.updateLayout(container, opts); + } + + getLayoutContainer(): HTMLElement { + return this.layoutContainer; + } + + /** + * Clear dimensions cache to free memory + */ + clearCache(): void { + this.dimensionsCache.clear(); + } + + // ============================================================================ + // PRIVATE UTILITY METHODS (DOM Helpers) + // ============================================================================ + + private getCssProperty(el: HTMLVideoElement | HTMLElement, propertyName: any, value?: string): void | string { + if (value !== undefined) { + // Set one CSS property + el.style[propertyName] = value; + } else if (typeof propertyName === 'object') { + // Set several CSS properties at once + Object.keys(propertyName).forEach((key) => { + this.getCssProperty(el, key, propertyName[key]); + }); + } else { + // Get the CSS property + const computedStyle = window.getComputedStyle(el); + let currentValue = computedStyle.getPropertyValue(propertyName); + + if (currentValue === '') { + currentValue = el.style[propertyName]; + } + return currentValue; + } + } + + private height(el: HTMLElement) { + const { offsetHeight } = el; + + if (offsetHeight > 0) { + return `${offsetHeight}px`; + } + return this.getCssProperty(el, 'height'); + } + + private width(el: HTMLElement) { + const { offsetWidth } = el; + + if (offsetWidth > 0) { + return `${offsetWidth}px`; + } + return this.getCssProperty(el, 'width'); + } + + /** + * @hidden + */ + private getChildDims(child: HTMLVideoElement | HTMLElement): ElementDimensions { + if (child instanceof HTMLVideoElement) { + if (child.videoHeight && child.videoWidth) { + return { + height: child.videoHeight, + width: child.videoWidth + }; + } + } else if (child instanceof HTMLElement) { + const video = child.querySelector('video'); + if (video instanceof HTMLVideoElement && video.videoHeight && video.videoWidth) { + return { + height: video.videoHeight, + width: video.videoWidth + }; + } + } + return { + height: LAYOUT_CONSTANTS.DEFAULT_VIDEO_HEIGHT, + width: LAYOUT_CONSTANTS.DEFAULT_VIDEO_WIDTH + }; + } + + /** + * @hidden + */ + private getCSSNumber(elem: HTMLElement, prop: string): number { + const cssStr = this.getCssProperty(elem, prop); + return cssStr ? parseInt(cssStr, 10) : 0; + } + + /** + * @hidden + */ + // Really cheap UUID function + private cheapUUID(): string { + return Math.floor(Math.random() * 100000000).toString(); + } + + /** + * @hidden + */ + private getHeight(elem: HTMLElement): number { + const heightStr = this.height(elem); + return heightStr ? parseInt(heightStr, 10) : 0; + } + + /** + * @hidden + */ + private getWidth(elem: HTMLElement): number { + const widthStr = this.width(elem); + return widthStr ? parseInt(widthStr, 10) : 0; + } + + /** + * @hidden + */ + private filterDisplayNone(element: HTMLElement) { + return this.getCssProperty(element, 'display') !== 'none'; + } +} diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts index 23515c4d0..26a577973 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { LayoutClass } from '../../models/layout.model'; +import { LayoutClass } from '../../models/layout/layout.model'; /** * @internal diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/layout/layout.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/layout/layout.service.ts index 2b16f611d..3db255b74 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/layout/layout.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/layout/layout.service.ts @@ -1,6 +1,6 @@ import { Injectable, effect } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { LayoutAlignment, LayoutClass, OpenViduLayout, OpenViduLayoutOptions } from '../../models/layout.model'; +import { LayoutAlignment, LayoutClass, OpenViduLayout, OpenViduLayoutOptions } from '../../models/layout/layout.model'; import { ILogger } from '../../models/logger.model'; import { LoggerService } from '../logger/logger.service'; import { ViewportService } from '../viewport/viewport.service'; 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 162a466d7..8c876d3b0 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,7 +38,7 @@ export * from './lib/models/broadcasting.model'; export * from './lib/models/data-topic.model'; export * from './lib/models/device.model'; export * from './lib/models/lang.model'; -export * from './lib/models/layout.model'; +export * from './lib/models/layout/layout.model'; export * from './lib/models/logger.model'; export * from './lib/models/panel.model'; export * from './lib/models/participant.model';