From 3c02121ebe3004c1cb21eb0b8599db76215309c3 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 22 Dec 2025 16:55:57 +0100 Subject: [PATCH] ov-components: enhance layout flexibility with additional element slots and improved rendering logic --- .../components/layout/layout.component.html | 14 +- .../lib/components/layout/layout.component.ts | 12 +- .../template/internals.directive.ts | 42 +++- .../src/lib/models/layout.model.ts | 204 ++++++++---------- .../src/lib/services/layout/layout.service.ts | 2 +- .../template/template-manager.service.ts | 24 ++- .../src/public-api.ts | 25 +-- 7 files changed, 171 insertions(+), 152 deletions(-) diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.html b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.html index 7b4da5f33..4a3f3db9e 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.html +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.html @@ -1,5 +1,10 @@
+ + @if (layoutAdditionalElementsTemplate && templateConfig.layoutAdditionalElementsSlot === 'top') { + + } +
- - @if (layoutAdditionalElementsTemplate) { + + @if (layoutAdditionalElementsTemplate && (templateConfig.layoutAdditionalElementsSlot === 'default' || !templateConfig.layoutAdditionalElementsSlot)) { } @@ -40,6 +45,11 @@ >
+ + + @if (layoutAdditionalElementsTemplate && templateConfig.layoutAdditionalElementsSlot === 'bottom') { + + }
diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.ts index 097dd9db8..48e7271f0 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/layout/layout.component.ts @@ -1,5 +1,6 @@ import { LayoutAdditionalElementsDirective } from '../../directives/template/internals.directive'; +import { CdkDrag } from '@angular/cdk/drag-drop'; import { AfterViewInit, ChangeDetectionStrategy, @@ -15,13 +16,12 @@ import { } from '@angular/core'; import { combineLatest, map, Subject, takeUntil } from 'rxjs'; import { StreamDirective } from '../../directives/template/openvidu-components-angular.directive'; -import { ParticipantTrackPublication, ParticipantModel } from '../../models/participant.model'; -import { LayoutService } from '../../services/layout/layout.service'; -import { ParticipantService } from '../../services/participant/participant.service'; -import { CdkDrag } from '@angular/cdk/drag-drop'; -import { PanelService } from '../../services/panel/panel.service'; -import { GlobalConfigService } from '../../services/config/global-config.service'; +import { ParticipantModel, ParticipantTrackPublication } from '../../models/participant.model'; import { OpenViduComponentsConfigService } from '../../services/config/directive-config.service'; +import { GlobalConfigService } from '../../services/config/global-config.service'; +import { LayoutService } from '../../services/layout/layout.service'; +import { PanelService } from '../../services/panel/panel.service'; +import { ParticipantService } from '../../services/participant/participant.service'; import { LayoutTemplateConfiguration, TemplateManagerService } from '../../services/template/template-manager.service'; /** diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/internals.directive.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/internals.directive.ts index 0b8999e31..0505ee76b 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/internals.directive.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/internals.directive.ts @@ -181,7 +181,7 @@ * @internal */ -import { Directive, TemplateRef, ViewContainerRef } from '@angular/core'; +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; @Directive({ selector: '[ovPreJoin]', @@ -253,14 +253,27 @@ export class LeaveButtonDirective { * as additional layout elements within the videoconference UI. * This enables you to extend the layout with extra controls, banners, or any custom UI. * - * Usage example: + * You can specify a slot to control where the element is positioned: + * - 'top': Position at the top of the layout (after local participant, before remote participants) + * - 'bottom': Position at the bottom of the layout (after all participants) + * - 'default' or no slot: Position after local participant (default behavior) + * + * Usage examples: * ```html * + * * - *
- * - * Extra layout element - *
+ *
Banner
+ *
+ * + * + * + *
Top Bar
+ *
+ * + * + * + *
Footer Info
*
*
* ``` @@ -270,10 +283,27 @@ export class LeaveButtonDirective { standalone: false }) export class LayoutAdditionalElementsDirective { + /** + * Slot position: 'top', 'bottom', or 'default' + */ + slot: 'top' | 'bottom' | 'default' = 'default'; + constructor( public template: TemplateRef, public container: ViewContainerRef ) {} + + /** + * @ignore + */ + @Input('ovLayoutAdditionalElements') + set ovLayoutAdditionalElements(slot: 'top' | 'bottom' | 'default' | '') { + if (slot === 'top' || slot === 'bottom' || slot === 'default') { + this.slot = slot; + } else { + this.slot = 'default'; + } + } } /** 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 index 6442a4187..f71e4a415 100644 --- 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 @@ -5,6 +5,7 @@ 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', @@ -42,6 +43,7 @@ export interface ElementDimensions { width: number; big?: boolean; small?: boolean; + topBar?: boolean; } /** @@ -200,7 +202,8 @@ export class OpenViduLayout { const elements = children.map((element) => { const res = this.getChildDims(element); res.big = element.classList.contains(this.opts.bigClass); - res.small = element.classList.contains(this.opts.smallClass); + res.small = element.classList.contains(LayoutClass.SMALL_ELEMENT); + res.topBar = element.classList.contains(LayoutClass.TOP_BAR_ELEMENT); return res; }); @@ -545,13 +548,15 @@ export class OpenViduLayout { 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 } = { big: null, normal: null, small: null }; + 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 three categories: big, small, and normal + // Separate elements into categories: big, small, topbar, and normal const bigOnes = elements.filter((element, idx) => { if (element.big) { bigIndices.push(idx); @@ -559,23 +564,31 @@ export class OpenViduLayout { } 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.small) { + if (!element.big && !element.topBar && element.small) { smallIndices.push(idx); return true; } return false; }); const normalOnes = elements.filter((element, idx) => { - if (!element.big && !element.small) { + 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)) { - // Scenario: Big elements with normal/small elements + 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; @@ -622,7 +635,7 @@ export class OpenViduLayout { maxRatio, containerWidth, containerHeight - bigHeight, - normalOnes.length + smallOnes.length, + normalOnes.length + smallOnes.length + topBarOnes.length, smallMaxWidth, smallMaxHeight ); @@ -677,7 +690,7 @@ export class OpenViduLayout { maxRatio, containerWidth - bigWidth, containerHeight, - normalOnes.length + smallOnes.length, + normalOnes.length + smallOnes.length + topBarOnes.length, smallMaxWidth, smallMaxHeight ); @@ -718,16 +731,16 @@ export class OpenViduLayout { height: containerHeight - offsetTop }; } - } else if (bigOnes.length > 0 && smallOnes.length === 0) { - // We only have one bigOne just center it + } 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) { - // Only normal and/or small elements + } else if (normalOnes.length > 0 || smallOnes.length > 0 || topBarOnes.length > 0) { + // Only normal, small, and/or topbar elements areas.normal = { top: offsetTop, left: offsetLeft, @@ -755,22 +768,67 @@ export class OpenViduLayout { ); } if (areas.normal) { - // Calculate equivalent "normal-sized" count considering small elements take less space - // Treat each small element as taking up a fraction of a normal element's space - const smallElementSpaceFactor = 0.25; // Small elements take ~25% of normal element space - const equivalentNormalCount = normalOnes.length + (smallOnes.length * smallElementSpaceFactor); + let currentTop = areas.normal.top; + let remainingHeight = areas.normal.height; - // Calculate layout for all elements together as if they were normal-sized - const allNormalAreaElements = [...normalOnes, ...smallOnes]; + // 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); - if (allNormalAreaElements.length > 0) { - // Get dimensions as if all elements were normal-sized - const allBoxes = this.getLayoutAux( + 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: areas.normal.height, + containerHeight: Math.max(0, remainingHeight), offsetLeft: areas.normal.left, - offsetTop: areas.normal.top, + offsetTop: currentTop, fixedRatio, minRatio, maxRatio, @@ -779,97 +837,8 @@ export class OpenViduLayout { maxHeight: areas.big ? maxHeight : maxHeight, scaleLastRow }, - allNormalAreaElements + normalOnes ); - - // Split boxes and adjust small elements - normalBoxes = allBoxes.slice(0, normalOnes.length); - const rawSmallBoxes = allBoxes.slice(normalOnes.length); - - // Adjust small element boxes to use restricted dimensions and reposition them - // to utilize space efficiently - smallBoxes = rawSmallBoxes.map((box, idx) => { - // Calculate restricted size while maintaining aspect ratio - const restrictedWidth = Math.min(box.width, smallMaxWidth); - const restrictedHeight = Math.min(box.height, smallMaxHeight); - - // Maintain the position but adjust size - return { - left: box.left, - top: box.top, - width: restrictedWidth, - height: restrictedHeight - }; - }); - - // If there are small elements, try to compact them and redistribute space - if (smallOnes.length > 0 && normalOnes.length > 0) { - // Recalculate normal elements with more space since small elements take less - const adjustedDimensions = this.getBestDimensions( - minRatio, - maxRatio, - areas.normal.width, - areas.normal.height, - equivalentNormalCount, - areas.big ? maxWidth : maxWidth, - areas.big ? maxHeight : maxHeight - ); - - // If the adjusted dimensions give us bigger normal elements, recalculate - if (normalBoxes.length > 0 && - adjustedDimensions.targetHeight > normalBoxes[0].height) { - normalBoxes = this.getLayoutAux( - { - containerWidth: areas.normal.width, - containerHeight: areas.normal.height, - offsetLeft: areas.normal.left, - offsetTop: areas.normal.top, - fixedRatio, - minRatio, - maxRatio, - alignItems: areas.big ? smallAlignItems : alignItems, - maxWidth: areas.big ? maxWidth : maxWidth, - maxHeight: areas.big ? maxHeight : maxHeight, - scaleLastRow - }, - normalOnes - ); - - // Position small elements in remaining space (bottom or side) - const normalMaxBottom = normalBoxes.length > 0 ? Math.max(...normalBoxes.map(b => b.top + b.height)) : areas.normal.top; - const normalMaxRight = normalBoxes.length > 0 ? Math.max(...normalBoxes.map(b => b.left + b.width)) : areas.normal.left; - - // Position small elements at the end of the layout - const spaceAtBottom = (areas.normal.top + areas.normal.height) - normalMaxBottom; - const spaceAtRight = (areas.normal.left + areas.normal.width) - normalMaxRight; - - let smallStartX = areas.normal.left; - let smallStartY = normalMaxBottom; - let availableWidth = areas.normal.width; - - // Choose best positioning based on available space - if (spaceAtBottom < smallMaxHeight && spaceAtRight >= smallMaxWidth) { - // Position to the right - smallStartX = normalMaxRight; - smallStartY = areas.normal.top; - availableWidth = spaceAtRight; - } - - // Arrange small elements in a compact grid - const smallCols = Math.floor(availableWidth / smallMaxWidth); - smallBoxes = smallOnes.map((_, idx) => { - const col = idx % Math.max(1, smallCols); - const row = Math.floor(idx / Math.max(1, smallCols)); - - return { - left: smallStartX + (col * smallMaxWidth), - top: smallStartY + (row * smallMaxHeight), - width: smallMaxWidth, - height: smallMaxHeight - }; - }); - } - } } } @@ -877,15 +846,22 @@ export class OpenViduLayout { 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; } 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 862dbad09..2b16f611d 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 @@ -101,7 +101,7 @@ export class LayoutService { maxWidth: Infinity, maxHeight: Infinity, smallMaxWidth: Infinity, - smallMaxHeight: Infinity, + smallMaxHeight: 80, bigMaxWidth: Infinity, bigMaxHeight: Infinity, scaleLastRow: true, diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/template/template-manager.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/template/template-manager.service.ts index f3cbf2b00..341598f3a 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/template/template-manager.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/template/template-manager.service.ts @@ -1,6 +1,12 @@ import { Injectable, TemplateRef } from '@angular/core'; -import { ILogger } from '../../models/logger.model'; -import { LoggerService } from '../logger/logger.service'; +import { + LayoutAdditionalElementsDirective, + LeaveButtonDirective, + ParticipantPanelAfterLocalParticipantDirective, + PreJoinDirective, + SettingsPanelGeneralAdditionalElementsDirective, + ToolbarMoreOptionsAdditionalMenuItemsDirective +} from '../../directives/template/internals.directive'; import { ActivitiesPanelDirective, AdditionalPanelsDirective, @@ -15,14 +21,8 @@ import { ToolbarAdditionalPanelButtonsDirective, ToolbarDirective } from '../../directives/template/openvidu-components-angular.directive'; -import { - PreJoinDirective, - ParticipantPanelAfterLocalParticipantDirective, - LayoutAdditionalElementsDirective, - LeaveButtonDirective, - SettingsPanelGeneralAdditionalElementsDirective, - ToolbarMoreOptionsAdditionalMenuItemsDirective -} from '../../directives/template/internals.directive'; +import { ILogger } from '../../models/logger.model'; +import { LoggerService } from '../logger/logger.service'; /** * Configuration object for all templates in the videoconference component @@ -89,6 +89,7 @@ export interface ToolbarTemplateConfiguration { export interface LayoutTemplateConfiguration { layoutStreamTemplate?: TemplateRef; layoutAdditionalElementsTemplate?: TemplateRef; + layoutAdditionalElementsSlot?: 'top' | 'bottom' | 'default'; } /** @@ -413,7 +414,8 @@ export class TemplateManagerService { return { layoutStreamTemplate: externalStream?.template, - layoutAdditionalElementsTemplate: externalLayoutAdditionalElements?.template + layoutAdditionalElementsTemplate: externalLayoutAdditionalElements?.template, + layoutAdditionalElementsSlot: externalLayoutAdditionalElements?.slot || 'default' }; } 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 742e5096e..162a466d7 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 @@ -5,6 +5,7 @@ // Components export * from './lib/admin/admin-dashboard/admin-dashboard.component'; export * from './lib/admin/admin-login/admin-login.component'; +export * from './lib/components/landscape-warning/landscape-warning.component'; export * from './lib/components/layout/layout.component'; export * from './lib/components/panel/activities-panel/activities-panel.component'; export * from './lib/components/panel/activities-panel/broadcasting-activity/broadcasting-activity.component'; @@ -14,11 +15,10 @@ export * from './lib/components/panel/panel.component'; export * from './lib/components/panel/participants-panel/participant-panel-item/participant-panel-item.component'; export * from './lib/components/panel/participants-panel/participants-panel/participants-panel.component'; export * from './lib/components/stream/stream.component'; -export * from './lib/components/toolbar/toolbar.component'; export * from './lib/components/toolbar/toolbar-media-buttons/toolbar-media-buttons.component'; export * from './lib/components/toolbar/toolbar-panel-buttons/toolbar-panel-buttons.component'; +export * from './lib/components/toolbar/toolbar.component'; export * from './lib/components/videoconference/videoconference.component'; -export * from './lib/components/landscape-warning/landscape-warning.component'; export * from './lib/config/openvidu-components-angular.config'; // Directives export * from './lib/directives/api/activities-panel.directive'; @@ -35,18 +35,19 @@ export * from './lib/directives/template/openvidu-components-angular.directive'; export * from './lib/directives/template/openvidu-components-angular.directive.module'; // Models 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/logger.model'; export * from './lib/models/panel.model'; export * from './lib/models/participant.model'; export * from './lib/models/recording.model'; -export * from './lib/models/data-topic.model'; export * from './lib/models/room.model'; -export * from './lib/models/toolbar.model'; -export * from './lib/models/logger.model'; export * from './lib/models/storage.model'; -export * from './lib/models/lang.model'; export * from './lib/models/theme.model'; +export * from './lib/models/toolbar.model'; export * from './lib/models/viewport.model'; -export * from './lib/models/device.model'; // Pipes export * from './lib/pipes/participant.pipe'; export * from './lib/pipes/recording.pipe'; @@ -55,21 +56,21 @@ export * from './lib/pipes/translate.pipe'; export * from './lib/services/action/action.service'; export * from './lib/services/broadcasting/broadcasting.service'; export * from './lib/services/chat/chat.service'; +export * from './lib/services/config/global-config.service'; +export * from './lib/services/e2ee/e2ee.service'; export * from './lib/services/layout/layout.service'; +export * from './lib/services/logger/logger.service'; export * from './lib/services/openvidu/openvidu.service'; export * from './lib/services/panel/panel.service'; export * from './lib/services/participant/participant.service'; export * from './lib/services/recording/recording.service'; -export * from './lib/services/config/global-config.service'; -export * from './lib/services/logger/logger.service'; export * from './lib/services/storage/storage.service'; -export * from './lib/services/translate/translate.service'; export * from './lib/services/theme/theme.service'; +export * from './lib/services/translate/translate.service'; export * from './lib/services/viewport/viewport.service'; -export * from './lib/services/e2ee/e2ee.service'; //Modules -export * from './lib/openvidu-components-angular.module'; export * from './lib/config/custom-cdk-overlay'; export * from './lib/openvidu-components-angular-ui.module'; +export * from './lib/openvidu-components-angular.module'; export * from 'livekit-client';