From 4bf351b2dfaab100782c88bb0f64eb86570fb765 Mon Sep 17 00:00:00 2001
From: Carlos Santos <4a.santos@gmail.com>
Date: Thu, 31 Jul 2025 13:47:35 +0200
Subject: [PATCH] ov-components: Add layout additional elements directive for
customizable UI extensions
---
.../components/layout/layout.component.html | 5 +
.../lib/components/layout/layout.component.ts | 103 +++++++++++++-----
.../components/pre-join/pre-join.component.ts | 2 +-
.../videoconference.component.html | 4 +
.../videoconference.component.ts | 65 ++++++++---
.../template/internals.directive.ts | 30 ++++-
...idu-components-angular.directive.module.ts | 8 +-
.../template/template-manager.service.ts | 36 +++++-
8 files changed, 208 insertions(+), 45 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 c2f734ca..c8c53b98 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
@@ -19,6 +19,11 @@
+
+ @if (layoutAdditionalElementsTemplate) {
+
+ }
+
;
+ /**
+ * @ignore
+ */
+ @ContentChild('layoutAdditionalElements', { read: TemplateRef }) layoutAdditionalElementsTemplate: TemplateRef
;
+
/**
* @ignore
*/
@@ -62,9 +70,27 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
// is inside of the layout component tagged with '*ovLayout' directive
if (externalStream) {
this.streamTemplate = externalStream.template;
+ this.updateTemplatesAndMarkForCheck();
}
}
+ /**
+ * @ignore
+ */
+ @ContentChild(LayoutAdditionalElementsDirective) set externalAdditionalElements(
+ externalAdditionalElements: LayoutAdditionalElementsDirective
+ ) {
+ if (externalAdditionalElements) {
+ this._externalLayoutAdditionalElements = externalAdditionalElements;
+ this.updateTemplatesAndMarkForCheck();
+ }
+ }
+
+ /**
+ * @ignore
+ */
+ templateConfig: LayoutTemplateConfiguration = {};
+
localParticipant: ParticipantModel | undefined;
remoteParticipants: ParticipantModel[] = [];
/**
@@ -72,6 +98,9 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
*/
captionsEnabled = true;
+ private _externalStream?: StreamDirective;
+ private _externalLayoutAdditionalElements?: LayoutAdditionalElementsDirective;
+
private destroy$ = new Subject();
private resizeObserver: ResizeObserver;
private resizeTimeout: NodeJS.Timeout;
@@ -87,10 +116,13 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
private participantService: ParticipantService,
private globalService: GlobalConfigService,
private directiveService: OpenViduComponentsConfigService,
- private cd: ChangeDetectorRef
+ private cd: ChangeDetectorRef,
+ private templateManagerService: TemplateManagerService
) {}
ngOnInit(): void {
+ this.setupTemplates();
+
this.subscribeToParticipants();
this.subscribeToCaptions();
}
@@ -121,34 +153,55 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
return track;
}
+ private setupTemplates() {
+ this.templateConfig = this.templateManagerService.setupLayoutTemplates(
+ this._externalStream,
+ this._externalLayoutAdditionalElements
+ );
+
+ // Apply templates to component properties for backward compatibility
+ this.applyTemplateConfiguration();
+ }
+
+ private applyTemplateConfiguration() {
+ if (this.templateConfig.layoutStreamTemplate) {
+ this.streamTemplate = this.templateConfig.layoutStreamTemplate;
+ }
+ if (this.templateConfig.layoutAdditionalElementsTemplate) {
+ this.layoutAdditionalElementsTemplate = this.templateConfig.layoutAdditionalElementsTemplate;
+ }
+ }
+
+ /**
+ * @internal
+ * Updates templates and triggers change detection
+ */
+ private updateTemplatesAndMarkForCheck(): void {
+ this.setupTemplates();
+ this.cd.markForCheck();
+ }
+
private subscribeToCaptions() {
- this.layoutService.captionsTogglingObs
- .pipe(takeUntil(this.destroy$))
- .subscribe((value: boolean) => {
- this.captionsEnabled = value;
- this.cd.markForCheck();
- this.layoutService.update();
- });
+ this.layoutService.captionsTogglingObs.pipe(takeUntil(this.destroy$)).subscribe((value: boolean) => {
+ this.captionsEnabled = value;
+ this.cd.markForCheck();
+ this.layoutService.update();
+ });
}
private subscribeToParticipants() {
- this.participantService.localParticipant$
- .pipe(takeUntil(this.destroy$))
- .subscribe((p) => {
- if (p) {
- this.localParticipant = p;
- if (!this.localParticipant?.isMinimized) {
- this.videoIsAtRight = false;
- }
- this.layoutService.update();
- this.cd.markForCheck();
+ this.participantService.localParticipant$.pipe(takeUntil(this.destroy$)).subscribe((p) => {
+ if (p) {
+ this.localParticipant = p;
+ if (!this.localParticipant?.isMinimized) {
+ this.videoIsAtRight = false;
}
- });
+ this.layoutService.update();
+ this.cd.markForCheck();
+ }
+ });
- combineLatest([
- this.participantService.remoteParticipants$,
- this.directiveService.layoutRemoteParticipants$
- ])
+ combineLatest([this.participantService.remoteParticipants$, this.directiveService.layoutRemoteParticipants$])
.pipe(
map(([serviceParticipants, directiveParticipants]) =>
directiveParticipants !== undefined ? directiveParticipants : serviceParticipants
@@ -219,9 +272,7 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
}
};
- this.cdkDrag.released
- .pipe(takeUntil(this.destroy$))
- .subscribe(handler);
+ this.cdkDrag.released.pipe(takeUntil(this.destroy$)).subscribe(handler);
if (this.globalService.isProduction()) return;
// Just for allow E2E testing with drag and drop
diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/pre-join/pre-join.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/pre-join/pre-join.component.ts
index 39706b02..eedcfed0 100644
--- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/pre-join/pre-join.component.ts
+++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/pre-join/pre-join.component.ts
@@ -100,7 +100,7 @@ export class PreJoinComponent implements OnInit, OnDestroy {
this.cdkSrv.setSelector('body');
if (this.shouldRemoveTracksWhenComponentIsDestroyed) {
- this.tracks.forEach((track) => {
+ this.tracks?.forEach((track) => {
track.stop();
});
}
diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.html b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.html
index 0a2f1787..e577cc54 100644
--- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.html
+++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.html
@@ -173,6 +173,10 @@
+
+
+
+
diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts
index ab9cc6ce..97600bed 100644
--- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts
+++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/components/videoconference/videoconference.component.ts
@@ -1,5 +1,16 @@
import { animate, style, transition, trigger } from '@angular/animations';
-import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, OnDestroy, Output, TemplateRef, ViewChild } from '@angular/core';
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ContentChild,
+ EventEmitter,
+ OnDestroy,
+ Output,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
import { Subject, filter, skip, take, takeUntil } from 'rxjs';
import {
ActivitiesPanelDirective,
@@ -23,7 +34,12 @@ import { DeviceService } from '../../services/device/device.service';
import { LoggerService } from '../../services/logger/logger.service';
import { OpenViduService } from '../../services/openvidu/openvidu.service';
import { StorageService } from '../../services/storage/storage.service';
-import { TemplateManagerService, TemplateConfiguration, ExternalDirectives, DefaultTemplates } from '../../services/template/template-manager.service';
+import {
+ TemplateManagerService,
+ TemplateConfiguration,
+ ExternalDirectives,
+ DefaultTemplates
+} from '../../services/template/template-manager.service';
import { Room } from 'livekit-client';
import { ParticipantLeftEvent, ParticipantModel } from '../../models/participant.model';
import { CustomDevice } from '../../models/device.model';
@@ -42,7 +58,11 @@ import {
} from '../../models/recording.model';
import { BroadcastingStartRequestedEvent, BroadcastingStopRequestedEvent } from '../../models/broadcasting.model';
import { LangOption } from '../../models/lang.model';
-import { ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from '../../directives/template/internals.directive';
+import {
+ LayoutAdditionalElementsDirective,
+ ParticipantPanelAfterLocalParticipantDirective,
+ PreJoinDirective
+} from '../../directives/template/internals.directive';
/**
* The **VideoconferenceComponent** is the parent of all OpenVidu components.
@@ -54,18 +74,21 @@ import { ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from
styleUrls: ['./videoconference.component.scss'],
animations: [
trigger('inOutAnimation', [
- transition(':enter', [style({ opacity: 0 }), animate(`${VideoconferenceComponent.ANIMATION_DURATION_MS}ms ease-out`, style({ opacity: 1 }))])
+ transition(':enter', [
+ style({ opacity: 0 }),
+ animate(`${VideoconferenceComponent.ANIMATION_DURATION_MS}ms ease-out`, style({ opacity: 1 }))
+ ])
// transition(':leave', [style({ opacity: 1 }), animate('50ms ease-in', style({ opacity: 0.9 }))])
])
],
standalone: false
})
export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
-
// Constants
private static readonly PARTICIPANT_NAME_TIMEOUT_MS = 1000;
private static readonly ANIMATION_DURATION_MS = 300;
- private static readonly MATERIAL_ICONS_URL = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&icon_names=background_replace,keep_off';
+ private static readonly MATERIAL_ICONS_URL =
+ 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&icon_names=background_replace,keep_off';
private static readonly MATERIAL_ICONS_SELECTOR = 'link[href*="Material+Symbols+Outlined"]';
private static readonly SPINNER_DIAMETER = 50;
// *** Toolbar ***
@@ -133,7 +156,12 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
* @internal
*
*/
- @ContentChild(ParticipantPanelAfterLocalParticipantDirective) externalParticipantPanelAfterLocalParticipant: ParticipantPanelAfterLocalParticipantDirective;
+ @ContentChild(ParticipantPanelAfterLocalParticipantDirective)
+ externalParticipantPanelAfterLocalParticipant: ParticipantPanelAfterLocalParticipantDirective;
+ /**
+ * @internal
+ */
+ @ContentChild(LayoutAdditionalElementsDirective) externalLayoutAdditionalElements: LayoutAdditionalElementsDirective;
/**
* @internal
@@ -227,6 +255,10 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
* @internal
*/
openviduAngularPreJoinTemplate: TemplateRef;
+ /**
+ * @internal
+ */
+ ovLayoutAdditionalElementsTemplate: TemplateRef;
/**
* @internal
@@ -530,7 +562,8 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
participantPanelItemElements: this.externalParticipantPanelItemElements,
layout: this.externalLayout,
stream: this.externalStream,
- preJoin: this.externalPreJoin
+ preJoin: this.externalPreJoin,
+ layoutAdditionalElements: this.externalLayoutAdditionalElements
};
const defaultTemplates: DefaultTemplates = {
@@ -576,7 +609,8 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
this.openviduAngularAdditionalPanelsTemplate = this.templateConfig.additionalPanelsTemplate;
}
if (this.templateConfig.participantPanelAfterLocalParticipantTemplate) {
- this.openviduAngularParticipantPanelAfterLocalParticipantTemplate = this.templateConfig.participantPanelAfterLocalParticipantTemplate;
+ this.openviduAngularParticipantPanelAfterLocalParticipantTemplate =
+ this.templateConfig.participantPanelAfterLocalParticipantTemplate;
}
if (this.templateConfig.participantPanelItemElementsTemplate) {
this.openviduAngularParticipantPanelItemElementsTemplate = this.templateConfig.participantPanelItemElementsTemplate;
@@ -584,6 +618,9 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
if (this.templateConfig.preJoinTemplate) {
this.openviduAngularPreJoinTemplate = this.templateConfig.preJoinTemplate;
}
+ if (this.templateConfig.layoutAdditionalElementsTemplate) {
+ this.ovLayoutAdditionalElementsTemplate = this.templateConfig.layoutAdditionalElementsTemplate;
+ }
}
/**
@@ -624,7 +661,7 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
this.log.w('No participant name available when requesting token');
// Wait a bit and try again in case name is still propagating
setTimeout(() => {
- const retryName = this.libService.getCurrentParticipantName()|| this.latestParticipantName;
+ const retryName = this.libService.getCurrentParticipantName() || this.latestParticipantName;
if (retryName) {
this.log.d(`Retrying token request for participant: ${retryName}`);
this.onTokenRequested.emit(retryName);
@@ -774,9 +811,11 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
this.storageSrv.setParticipantName(name);
// If we're waiting for a participant name to proceed with joining, do it now
- if (this.componentState.state === VideoconferenceState.JOINING &&
+ if (
+ this.componentState.state === VideoconferenceState.JOINING &&
this.componentState.isRoomReady &&
- !this.componentState.showPrejoin) {
+ !this.componentState.showPrejoin
+ ) {
this.log.d('Participant name received, proceeding to join');
this.updateComponentState({
state: VideoconferenceState.READY_TO_CONNECT,
@@ -786,4 +825,4 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
}
});
}
-}
\ No newline at end of file
+}
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 44eb5589..8b266599 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
@@ -196,7 +196,7 @@ export class PreJoinDirective {
/**
- * The **ovParticipantPanelAfterLocalParticipant** directive allows you to inject custom HTML or Angular templates
+ * The ***ovParticipantPanelAfterLocalParticipant** directive allows you to inject custom HTML or Angular templates
* immediately after the local participant item in the participant panel.
* This enables you to extend the participant panel with additional controls, information, or UI elements.
*
@@ -221,4 +221,32 @@ export class ParticipantPanelAfterLocalParticipantDirective {
public template: TemplateRef,
public container: ViewContainerRef
) {}
+}
+
+/**
+ * The ***ovLayoutAdditionalElements** directive allows you to inject custom HTML or Angular templates
+ * 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:
+ * ```html
+ *
+ *
+ *
+ *
+ * Extra layout element
+ *
+ *
+ *
+ * ```
+ */
+@Directive({
+ selector: '[ovLayoutAdditionalElements]',
+ standalone: false
+})
+export class LayoutAdditionalElementsDirective {
+ constructor(
+ public template: TemplateRef,
+ public container: ViewContainerRef
+ ) {}
}
\ No newline at end of file
diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/openvidu-components-angular.directive.module.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/openvidu-components-angular.directive.module.ts
index 6442345e..25a55519 100644
--- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/openvidu-components-angular.directive.module.ts
+++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/directives/template/openvidu-components-angular.directive.module.ts
@@ -14,7 +14,7 @@ import {
ActivitiesPanelDirective,
BackgroundEffectsPanelDirective
} from './openvidu-components-angular.directive';
-import { ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from './internals.directive';
+import { LayoutAdditionalElementsDirective, ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from './internals.directive';
@NgModule({
declarations: [
@@ -31,7 +31,8 @@ import { ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from
ParticipantPanelItemElementsDirective,
ActivitiesPanelDirective,
PreJoinDirective,
- ParticipantPanelAfterLocalParticipantDirective
+ ParticipantPanelAfterLocalParticipantDirective,
+ LayoutAdditionalElementsDirective
// BackgroundEffectsPanelDirective
],
exports: [
@@ -48,7 +49,8 @@ import { ParticipantPanelAfterLocalParticipantDirective, PreJoinDirective } from
ParticipantPanelItemElementsDirective,
ActivitiesPanelDirective,
PreJoinDirective,
- ParticipantPanelAfterLocalParticipantDirective
+ ParticipantPanelAfterLocalParticipantDirective,
+ LayoutAdditionalElementsDirective
// BackgroundEffectsPanelDirective
]
})
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 794fb9a1..f77c4d63 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
@@ -15,7 +15,11 @@ import {
ToolbarAdditionalPanelButtonsDirective,
ToolbarDirective
} from '../../directives/template/openvidu-components-angular.directive';
-import { PreJoinDirective, ParticipantPanelAfterLocalParticipantDirective } from '../../directives/template/internals.directive';
+import {
+ PreJoinDirective,
+ ParticipantPanelAfterLocalParticipantDirective,
+ LayoutAdditionalElementsDirective
+} from '../../directives/template/internals.directive';
/**
* Configuration object for all templates in the videoconference component
@@ -41,6 +45,7 @@ export interface TemplateConfiguration {
// Layout templates
layoutTemplate: TemplateRef;
streamTemplate: TemplateRef;
+ layoutAdditionalElementsTemplate?: TemplateRef;
// PreJoin template
preJoinTemplate?: TemplateRef;
@@ -66,6 +71,14 @@ export interface ToolbarTemplateConfiguration {
toolbarAdditionalPanelButtonsTemplate?: TemplateRef;
}
+/**
+ * Configuration object for layout component templates
+ */
+export interface LayoutTemplateConfiguration {
+ layoutStreamTemplate?: TemplateRef;
+ layoutAdditionalElementsTemplate?: TemplateRef;
+}
+
/**
* Configuration object for participants panel component templates
*/
@@ -108,6 +121,7 @@ export interface ExternalDirectives {
layout?: LayoutDirective;
stream?: StreamDirective;
preJoin?: PreJoinDirective;
+ layoutAdditionalElements?: LayoutAdditionalElementsDirective;
}
/**
@@ -180,6 +194,11 @@ export class TemplateManagerService {
this.log.d('Setting EXTERNAL PARTICIPANT PANEL ITEM ELEMENTS');
}
+ if (externalDirectives.layoutAdditionalElements) {
+ this.log.d('Setting EXTERNAL ADDITIONAL LAYOUT ELEMENTS');
+ config.layoutAdditionalElementsTemplate = externalDirectives.layoutAdditionalElements.template;
+ }
+
this.log.d('Template setup completed', config);
return config;
}
@@ -349,6 +368,21 @@ export class TemplateManagerService {
};
}
+ /**
+ * Sets up templates for the LayoutComponent
+ */
+ setupLayoutTemplates(
+ externalStream?: StreamDirective,
+ externalLayoutAdditionalElements?: LayoutAdditionalElementsDirective
+ ): LayoutTemplateConfiguration {
+ this.log.d('Setting up layout templates...');
+
+ return {
+ layoutStreamTemplate: externalStream?.template,
+ layoutAdditionalElementsTemplate: externalLayoutAdditionalElements?.template
+ };
+ }
+
/**
* Sets up templates for the ParticipantsPanelComponent
*/