frontend: add hidden participants indicator component with responsive layouts

This commit is contained in:
Carlos Santos 2025-12-22 14:12:17 +01:00
parent 0ab6a48e13
commit 3a83efa668
7 changed files with 356 additions and 8 deletions

View File

@ -0,0 +1,42 @@
@if (count() > 0) {
<div class="hidden-participants-container" [class.horizontal]="isTopBarMode()" [class.vertical]="!isTopBarMode()">
@if (isTopBarMode()) {
<!-- Horizontal/Bar Layout -->
<div class="horizontal-content">
<div class="count-badge-horizontal">
{{ displayText() }}
</div>
<div class="text-horizontal">
<span class="count-number">{{ count() }}</span>
<span class="description">{{ descriptionText() }}</span>
</div>
</div>
} @else {
<!-- Vertical/Standard Layout -->
<div class="vertical-content">
<div class="header-section">
<div class="icon-group">
<svg class="participants-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
</svg>
</div>
<div class="title">Hidden Participants</div>
</div>
<div class="count-section">
<div class="count-badge-vertical">
{{ displayText() }}
</div>
<div class="count-label">
{{ count() }} {{ count() === 1 ? 'participant' : 'participants' }}
</div>
</div>
<div class="info-section">
<svg class="info-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>
<div class="info-text">Not currently visible in the layout</div>
</div>
</div>
}
</div>
}

View File

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

View File

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

View File

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

View File

@ -14,6 +14,15 @@
></ov-share-meeting-link>
</div>
</ng-container>
} @else if (isSmartMosaicActive() && hiddenParticipantsCount() > 0) {
<ng-container *ovLayoutAdditionalElements>
<div [ngClass]="{ 'OV_top-bar': showTopBarHiddenParticipantsIndicator() }">
<ov-hidden-participants-indicator
[count]="hiddenParticipantsCount()"
[mode]="showTopBarHiddenParticipantsIndicator() ? 'topbar' : 'standard'"
/>
</div>
</ng-container>
}
<ng-template #stream let-track>

View File

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

View File

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