frontend: add hidden participants indicator component with responsive layouts
This commit is contained in:
parent
0ab6a48e13
commit
3a83efa668
@ -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>
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
});
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user