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 */