ov-components: Add layout additional elements directive for customizable UI extensions

master
Carlos Santos 2025-07-31 13:47:35 +02:00
parent e9ecceeb77
commit 4bf351b2df
8 changed files with 208 additions and 45 deletions

View File

@ -19,6 +19,11 @@
<ng-container *ngTemplateOutlet="streamTemplate; context: { $implicit: track }"></ng-container>
</div>
<!-- Render additional layout elements injected via ovAdditionalLayoutElement -->
@if (layoutAdditionalElementsTemplate) {
<ng-container *ngTemplateOutlet="layoutAdditionalElementsTemplate"></ng-container>
}
<div
*ngFor="let track of remoteParticipants | tracks; trackBy: trackParticipantElement"
class="remote-participant"

View File

@ -1,3 +1,5 @@
import { LayoutAdditionalElementsDirective } from '../../directives/template/internals.directive';
import {
AfterViewInit,
ChangeDetectionStrategy,
@ -20,6 +22,7 @@ import { CdkDrag } from '@angular/cdk/drag-drop';
import { PanelService } from '../../services/panel/panel.service';
import { GlobalConfigService } from '../../services/config/global-config.service';
import { OpenViduComponentsConfigService } from '../../services/config/directive-config.service';
import { LayoutTemplateConfiguration, TemplateManagerService } from '../../services/template/template-manager.service';
/**
*
@ -39,6 +42,11 @@ export class LayoutComponent implements OnInit, OnDestroy, AfterViewInit {
*/
@ContentChild('stream', { read: TemplateRef }) streamTemplate: TemplateRef<any>;
/**
* @ignore
*/
@ContentChild('layoutAdditionalElements', { read: TemplateRef }) layoutAdditionalElementsTemplate: TemplateRef<any>;
/**
* @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<void>();
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

View File

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

View File

@ -173,6 +173,10 @@
<ng-template #stream let-track>
<ng-container *ngTemplateOutlet="openviduAngularStreamTemplate; context: { $implicit: track }"></ng-container>
</ng-template>
<ng-template #layoutAdditionalElements>
<ng-container *ngTemplateOutlet="ovLayoutAdditionalElementsTemplate"></ng-container>
</ng-template>
</ov-layout>
</ng-template>

View File

@ -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<any>;
/**
* @internal
*/
ovLayoutAdditionalElementsTemplate: TemplateRef<any>;
/**
* @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 {
}
});
}
}
}

View File

@ -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<any>,
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
* <ov-videoconference>
* <ng-container *ovLayoutAdditionalElements>
* <div class="my-custom-layout-element">
* <!-- Your custom HTML here -->
* <span>Extra layout element</span>
* </div>
* </ng-container>
* </ov-videoconference>
* ```
*/
@Directive({
selector: '[ovLayoutAdditionalElements]',
standalone: false
})
export class LayoutAdditionalElementsDirective {
constructor(
public template: TemplateRef<any>,
public container: ViewContainerRef
) {}
}

View File

@ -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
]
})

View File

@ -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<any>;
streamTemplate: TemplateRef<any>;
layoutAdditionalElementsTemplate?: TemplateRef<any>;
// PreJoin template
preJoinTemplate?: TemplateRef<any>;
@ -66,6 +71,14 @@ export interface ToolbarTemplateConfiguration {
toolbarAdditionalPanelButtonsTemplate?: TemplateRef<any>;
}
/**
* Configuration object for layout component templates
*/
export interface LayoutTemplateConfiguration {
layoutStreamTemplate?: TemplateRef<any>;
layoutAdditionalElementsTemplate?: TemplateRef<any>;
}
/**
* 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
*/