ov-components: enhance layout flexibility with additional element slots and improved rendering logic

master
Carlos Santos 2025-12-22 16:55:57 +01:00
parent ff492a1f22
commit 3c02121ebe
7 changed files with 171 additions and 152 deletions

View File

@ -1,5 +1,10 @@
<div class="container" [ngClass]="{ withCaptions: captionsEnabled, withMargin: localParticipant.isMinimized }">
<div id="layout" class="layout" #layout>
<!-- Top slot: Render elements that should appear at the top -->
@if (layoutAdditionalElementsTemplate && templateConfig.layoutAdditionalElementsSlot === 'top') {
<ng-container *ngTemplateOutlet="layoutAdditionalElementsTemplate"></ng-container>
}
<div
#localLayoutElement
*ngFor="let track of localParticipant.tracks; trackBy: trackParticipantElement"
@ -21,8 +26,8 @@
<ng-container *ngTemplateOutlet="streamTemplate; context: { $implicit: track }"></ng-container>
</div>
<!-- Render additional layout elements injected via ovAdditionalLayoutElement -->
@if (layoutAdditionalElementsTemplate) {
<!-- Default slot: Render additional layout elements (backward compatibility and default position) -->
@if (layoutAdditionalElementsTemplate && (templateConfig.layoutAdditionalElementsSlot === 'default' || !templateConfig.layoutAdditionalElementsSlot)) {
<ng-container *ngTemplateOutlet="layoutAdditionalElementsTemplate"></ng-container>
}
@ -40,6 +45,11 @@
>
<ng-container *ngTemplateOutlet="streamTemplate; context: { $implicit: track }"></ng-container>
</div>
<!-- Bottom slot: Render elements that should appear at the bottom -->
@if (layoutAdditionalElementsTemplate && templateConfig.layoutAdditionalElementsSlot === 'bottom') {
<ng-container *ngTemplateOutlet="layoutAdditionalElementsTemplate"></ng-container>
}
</div>
<!-- <ov-captions *ngIf="captionsEnabled" class="OV_ignored"></ov-captions> -->

View File

@ -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';
/**

View File

@ -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
* <ov-videoconference>
* <!-- Default position (after local participant) -->
* <ng-container *ovLayoutAdditionalElements>
* <div class="my-custom-layout-element">
* <!-- Your custom HTML here -->
* <span>Extra layout element</span>
* </div>
* <div class="my-banner">Banner</div>
* </ng-container>
*
* <!-- Top position -->
* <ng-container *ovLayoutAdditionalElements="'top'">
* <div class="top-bar">Top Bar</div>
* </ng-container>
*
* <!-- Bottom position -->
* <ng-container *ovLayoutAdditionalElements="'bottom'">
* <div class="bottom-info">Footer Info</div>
* </ng-container>
* </ov-videoconference>
* ```
@ -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<any>,
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';
}
}
}
/**

View File

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

View File

@ -101,7 +101,7 @@ export class LayoutService {
maxWidth: Infinity,
maxHeight: Infinity,
smallMaxWidth: Infinity,
smallMaxHeight: Infinity,
smallMaxHeight: 80,
bigMaxWidth: Infinity,
bigMaxHeight: Infinity,
scaleLastRow: true,

View File

@ -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<any>;
layoutAdditionalElementsTemplate?: TemplateRef<any>;
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'
};
}

View File

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