From 3a83efa668fab6bc2e4af766fa0eee583199608e Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 22 Dec 2025 14:12:17 +0100 Subject: [PATCH] frontend: add hidden participants indicator component with responsive layouts --- ...dden-participants-indicator.component.html | 42 ++++ ...dden-participants-indicator.component.scss | 227 ++++++++++++++++++ ...hidden-participants-indicator.component.ts | 40 +++ .../src/lib/components/index.ts | 9 +- .../meeting-custom-layout.component.html | 9 + .../meeting-custom-layout.component.scss | 5 +- .../meeting-custom-layout.component.ts | 32 ++- 7 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.ts diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.html new file mode 100644 index 00000000..68a346d9 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.html @@ -0,0 +1,42 @@ +@if (count() > 0) { +
+ @if (isTopBarMode()) { + +
+
+ {{ displayText() }} +
+
+ {{ count() }} + {{ descriptionText() }} +
+
+ } @else { + +
+
+
+ + + +
+
Hidden Participants
+
+
+
+ {{ displayText() }} +
+
+ {{ count() }} {{ count() === 1 ? 'participant' : 'participants' }} +
+
+
+ + + +
Not currently visible in the layout
+
+
+ } +
+} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.scss new file mode 100644 index 00000000..d34d9aaa --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.scss @@ -0,0 +1,227 @@ +.hidden-participants-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: linear-gradient(135deg, #1e1e1e 0%, #2d2d30 100%); + border-radius: 8px; + position: relative; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + } +} + +// ============================================ +// HORIZONTAL MODE (Barra superior compacta) +// ============================================ +.horizontal-content { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 0 24px; + width: 100%; + height: 100%; +} + +.count-badge-horizontal { + display: flex; + align-items: center; + justify-content: center; + min-width: 50px; + height: 50px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 50%; + font-size: 22px; + font-weight: 700; + color: #ffffff; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + flex-shrink: 0; +} + +.text-horizontal { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + + .count-number { + font-size: 24px; + font-weight: 700; + color: #ffffff; + } + + .description { + font-size: 14px; + font-weight: 500; + color: #b0b0b0; + letter-spacing: 0.3px; + } +} + +// ============================================ +// VERTICAL MODE (Tile estándar 16:9) +// ============================================ +.vertical-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + padding: 20px; + gap: 16px; +} + +.header-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; + + .icon-group { + display: flex; + align-items: center; + justify-content: center; + } + + .participants-icon { + width: 40px; + height: 40px; + color: #667eea; + opacity: 0.9; + } + + .title { + font-size: 16px; + font-weight: 600; + color: #ffffff; + text-align: center; + letter-spacing: 0.5px; + } +} + +.count-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + flex: 1; + justify-content: center; + + .count-badge-vertical { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 50%; + font-size: 36px; + font-weight: 700; + color: #ffffff; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5); + animation: pulse 2s ease-in-out infinite; + } + + .count-label { + font-size: 15px; + font-weight: 500; + color: #e0e0e0; + text-align: center; + letter-spacing: 0.3px; + } +} + +.info-section { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 6px; + width: 100%; + + .info-icon { + width: 20px; + height: 20px; + color: #667eea; + flex-shrink: 0; + } + + .info-text { + font-size: 12px; + font-weight: 500; + color: #b0b0b0; + line-height: 1.4; + letter-spacing: 0.2px; + } +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5); + } + 50% { + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.7); + } +} + +// Responsive adjustments +@media (max-width: 768px), (max-height: 500px) { + .count-badge-horizontal { + min-width: 40px; + height: 40px; + font-size: 18px; + } + + .text-horizontal { + .count-number { + font-size: 20px; + } + .description { + font-size: 12px; + } + } + + .vertical-content { + padding: 16px; + gap: 12px; + } + + .header-section { + .participants-icon { + width: 32px; + height: 32px; + } + .title { + font-size: 14px; + } + } + + .count-section { + .count-badge-vertical { + width: 64px; + height: 64px; + font-size: 28px; + } + .count-label { + font-size: 13px; + } + } + + .info-section { + padding: 10px 12px; + .info-text { + font-size: 11px; + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.ts new file mode 100644 index 00000000..38712433 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/hidden-participants-indicator/hidden-participants-indicator.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, input } from '@angular/core'; + +/** + * Component that displays an indicator for participants not visible in the current layout. + * This appears as an extra participant tile when using the "Last N" smart layout feature. + * Adapts its layout based on parent element class: + * - Horizontal (bar) layout when placed in a top bar. + * - Standard (vertical) layout otherwise. + */ +@Component({ + selector: 'ov-hidden-participants-indicator', + imports: [CommonModule], + templateUrl: './hidden-participants-indicator.component.html', + styleUrl: './hidden-participants-indicator.component.scss' +}) +export class HiddenParticipantsIndicatorComponent { + /** + * Number of hidden participants not currently visible in the layout + */ + count = input(0); + + mode = input<'topbar' | 'standard'>('standard'); + + protected isTopBarMode = computed(() => this.mode() === 'topbar'); + + constructor() {} + + /** + * Get the display text for the hidden participants count + */ + protected displayText = computed(() => { + if (this.count() === 0) return ''; + return `+${this.count()}`; + }); + + protected descriptionText = computed(() => { + return this.count() === 1 ? 'participant not visible' : 'participants not visible'; + }); +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts index 31264226..b5c74941 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts @@ -1,24 +1,25 @@ export * from './console-nav/console-nav.component'; export * from './dialogs/basic-dialog/dialog.component'; -export * from './dialogs/share-recording-dialog/share-recording-dialog.component'; export * from './dialogs/delete-room-dialog/delete-room-dialog.component'; +export * from './dialogs/share-recording-dialog/share-recording-dialog.component'; export * from './logo-selector/logo-selector.component'; export * from './pro-feature-badge/pro-feature-badge.component'; export * from './recording-lists/recording-lists.component'; export * from './recording-video-player/recording-video-player.component'; export * from './rooms-lists/rooms-lists.component'; export * from './selectable-card/selectable-card.component'; +export * from './share-meeting-link/share-meeting-link.component'; export * from './spinner/spinner.component'; export * from './step-indicator/step-indicator.component'; export * from './wizard-nav/wizard-nav.component'; -export * from './share-meeting-link/share-meeting-link.component'; // Meeting modular components -export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; export * from './meeting-lobby/meeting-lobby.component'; +export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; // Meeting components -export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; +export * from './hidden-participants-indicator/hidden-participants-indicator.component'; export * from './meeting-lobby/meeting-lobby.component'; +export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.html index 6c23bc29..f89a4215 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.html @@ -14,6 +14,15 @@ > + } @else if (isSmartMosaicActive() && hiddenParticipantsCount() > 0) { + +
+ +
+
} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.scss index 2974f02c..ae0c5e03 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.scss @@ -80,7 +80,10 @@ cursor: move; } - +.OV_top-bar { + box-sizing: border-box; + padding: 4px 4px 2px 4px !important; +} .main-share-meeting-link-container { background-color: var(--ov-surface-color); // Use ov-components variable diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.ts index 441ee384..632c251d 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-custom-layout/meeting-custom-layout.component.ts @@ -1,12 +1,18 @@ +import { CommonModule } from '@angular/common'; import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; import { ILogger, LoggerService, OpenViduComponentsUiModule, ParticipantModel } from 'openvidu-components-angular'; -import { ShareMeetingLinkComponent } from '../../../components'; +import { HiddenParticipantsIndicatorComponent, ShareMeetingLinkComponent } from '../../../components'; import { CustomParticipantModel } from '../../../models'; import { MeetingContextService, MeetingService, MeetLayoutService } from '../../../services'; @Component({ selector: 'ov-meeting-custom-layout', - imports: [OpenViduComponentsUiModule, ShareMeetingLinkComponent], + imports: [ + CommonModule, + OpenViduComponentsUiModule, + ShareMeetingLinkComponent, + HiddenParticipantsIndicatorComponent + ], templateUrl: './meeting-custom-layout.component.html', styleUrl: './meeting-custom-layout.component.scss' }) @@ -38,6 +44,26 @@ export class MeetingCustomLayoutComponent { private _visibleRemoteParticipants = signal([]); readonly visibleRemoteParticipants = this._visibleRemoteParticipants.asReadonly(); + protected readonly hiddenParticipantsCount = computed(() => { + const total = this.remoteParticipants().length; + const visible = this.visibleRemoteParticipants().length; + return Math.max(0, total - visible); + }); + + /** + * Indicates whether to show the hidden participants indicator in the top bar + * when in smart mosaic mode. + */ + protected readonly showTopBarHiddenParticipantsIndicator = computed(() => { + const localParticipant = this.meetingContextService.localParticipant()!; + const hasPinnedParticipant = + localParticipant.isPinned || this.remoteParticipants().some((p) => (p as CustomParticipantModel).isPinned); + const visibleParticipantsCount = this.visibleRemoteParticipants().length; + const showTopBar = + !hasPinnedParticipant && visibleParticipantsCount < this.layoutService.MAX_REMOTE_SPEAKERS_LIMIT; + return showTopBar; + }); + constructor() { this.setupSpeakerTrackingEffect(); this.setupParticipantCleanupEffect(); @@ -54,7 +80,7 @@ export class MeetingCustomLayoutComponent { this.meetingService.copyMeetingSpeakerLink(room); } - private isSmartMosaicActive(): boolean { + protected isSmartMosaicActive(): boolean { return this.isLayoutSwitchingAllowed() && this.layoutService.isSmartMosaicEnabled(); }