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.
master
Carlos Santos 2025-12-22 18:03:45 +01:00
parent 3c02121ebe
commit 961867941a
11 changed files with 1360 additions and 1027 deletions

View File

@ -14262,6 +14262,7 @@
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cli-truncate": "^4.0.0", "cli-truncate": "^4.0.0",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@ -21040,6 +21041,7 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -21368,6 +21370,7 @@
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/bonjour": "^3.5.13", "@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4", "@types/connect-history-api-fallback": "^1.5.4",

View File

@ -17,7 +17,7 @@ import { animate, style, transition, trigger } from '@angular/animations';
import { MatDrawerContainer, MatSidenav } from '@angular/material/sidenav'; import { MatDrawerContainer, MatSidenav } from '@angular/material/sidenav';
import { skip, Subject, takeUntil } from 'rxjs'; import { skip, Subject, takeUntil } from 'rxjs';
import { DataTopic } from '../../models/data-topic.model'; 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 { ILogger } from '../../models/logger.model';
import { PanelStatusInfo, PanelType } from '../../models/panel.model'; import { PanelStatusInfo, PanelType } from '../../models/panel.model';
import { RoomStatusData } from '../../models/room.model'; import { RoomStatusData } from '../../models/room.model';

View File

@ -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<ExtendedLayoutOptions & { offsetLeft: number; offsetTop: number }>,
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;
}
}

View File

@ -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<string, BestDimensions>();
/**
* 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}`;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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';
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { LayoutClass } from '../../models/layout.model'; import { LayoutClass } from '../../models/layout/layout.model';
/** /**
* @internal * @internal

View File

@ -1,6 +1,6 @@
import { Injectable, effect } from '@angular/core'; import { Injectable, effect } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; 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 { ILogger } from '../../models/logger.model';
import { LoggerService } from '../logger/logger.service'; import { LoggerService } from '../logger/logger.service';
import { ViewportService } from '../viewport/viewport.service'; import { ViewportService } from '../viewport/viewport.service';

View File

@ -38,7 +38,7 @@ export * from './lib/models/broadcasting.model';
export * from './lib/models/data-topic.model'; export * from './lib/models/data-topic.model';
export * from './lib/models/device.model'; export * from './lib/models/device.model';
export * from './lib/models/lang.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/logger.model';
export * from './lib/models/panel.model'; export * from './lib/models/panel.model';
export * from './lib/models/participant.model'; export * from './lib/models/participant.model';