ov-components: Revamp participant panel item for improved UI/UX and accessibility; add mute/unmute functionality and translations

master
Carlos Santos 2025-08-05 16:35:20 +02:00
parent 4bf351b2df
commit 00fcb0b115
13 changed files with 547 additions and 79 deletions

View File

@ -1,33 +1,76 @@
<mat-list> <mat-list>
<mat-list-item> <mat-list-item>
<div matListItemIcon class="participant-avatar" [style.background-color]="_participant.colorProfile"> <!-- Main participant container with improved structure -->
<mat-icon>person</mat-icon> <div class="participant-container" [attr.data-participant-id]="_participant?.sid">
</div> <!-- Avatar section with dynamic color -->
<h3 matListItemTitle class="participant-name">{{ _participant.name }} <div
<span *ngIf="_participant.isLocal"> ({{ 'PANEL.PARTICIPANTS.YOU' | translate }})</span> class="participant-avatar"
</h3> [style.background-color]="_participant?.colorProfile"
<p matListItemLine class="participant-subtitle">{{ _participant | tracksPublishedTypes }}</p> [attr.aria-label]="'Avatar for ' + participantDisplayName"
<!-- <p matListItemLine>
<span class="participant-subtitle"></span>
</p> -->
<div class="participant-action-buttons" matListItemMeta>
<button
mat-icon-button
id="mute-btn"
*ngIf="!_participant.isLocal && showMuteButton"
[class.warn-btn]="_participant.isMutedForcibly"
(click)="toggleMuteForcibly()"
[disableRipple]="true"
> >
<mat-icon *ngIf="!_participant.isMutedForcibly">volume_up</mat-icon> <mat-icon>person</mat-icon>
<mat-icon *ngIf="_participant.isMutedForcibly">volume_off</mat-icon> </div>
</button>
<!-- External item elements --> <!-- Content section with name and status -->
<ng-container *ngIf="participantPanelItemElementsTemplate"> <div class="participant-content">
<ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container> <div class="participant-name">
</ng-container> {{ participantDisplayName }}
<span *ngIf="isLocalParticipant" class="local-indicator">
{{ 'PANEL.PARTICIPANTS.YOU' | translate }}
</span>
</div>
<div class="participant-subtitle">
<span class="status-indicator">
{{ _participant | tracksPublishedTypes }}
</span>
<!-- Additional status indicators -->
<span *ngIf="_participant?.isMutedForcibly" class="status-indicator">
<mat-icon>volume_off</mat-icon>
{{ 'PANEL.PARTICIPANTS.MUTED' | translate }}
</span>
</div>
</div>
<!-- Action buttons section -->
<div class="participant-action-buttons">
<!-- Mute/Unmute button for remote participants -->
<button
mat-icon-button
id="mute-btn"
*ngIf="!isLocalParticipant && showMuteButton"
[class.warn-btn]="_participant?.isMutedForcibly"
(click)="toggleMuteForcibly()"
[disabled]="!_participant"
[disableRipple]="true"
[attr.aria-label]="
_participant?.isMutedForcibly
? ('PANEL.PARTICIPANTS.UNMUTE' | translate) + ' ' + participantDisplayName
: ('PANEL.PARTICIPANTS.MUTE' | translate) + ' ' + participantDisplayName
"
[matTooltip]="
_participant?.isMutedForcibly ? ('PANEL.PARTICIPANTS.UNMUTE' | translate) : ('PANEL.PARTICIPANTS.MUTE' | translate)
"
>
<mat-icon *ngIf="!_participant?.isMutedForcibly">volume_up</mat-icon>
<mat-icon *ngIf="_participant?.isMutedForcibly">volume_off</mat-icon>
</button>
<!-- External item elements with improved structure -->
<div class="external-elements" *ngIf="hasExternalElements">
<ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container>
</div>
</div>
</div> </div>
<!-- Content after local participant (only for local participant) -->
<!-- <div
class="after-local-content"
*ngIf="hasAfterLocalContent"
role="region"
[attr.aria-label]="'Additional content for local participant'"
>
<ng-container *ngTemplateOutlet="afterLocalParticipantTemplate"></ng-container>
</div> -->
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>

View File

@ -1,68 +1,419 @@
:host { :host {
// Container for the participant item
.participant-container {
position: relative;
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: var(--ov-surface-radius, 8px);
background-color: var(--ov-surface-background, #ffffff);
border-bottom: 1px solid var(--ov-surface-border, #e0e0e0);
transition: all 0.2s ease-in-out;
min-height: 64px;
// &:hover {
// background-color: var(--ov-surface-hover, #f5f5f5);
// transform: translateY(-1px);
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
// }
&:last-child {
border-bottom: none;
}
// Loading state
&.loading {
opacity: 0.7;
pointer-events: none;
&::after {
content: '';
position: absolute;
top: 50%;
right: 16px;
width: 16px;
height: 16px;
border: 2px solid var(--ov-primary-color, #1976d2);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
}
// Focus state for keyboard navigation
&:focus-within {
outline: 2px solid var(--ov-primary-color, #1976d2);
outline-offset: 2px;
}
}
// Avatar styling with improved design
.participant-avatar { .participant-avatar {
display: inherit; display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ov-surface-radius); border-radius: var(--ov-surface-radius);
margin: auto !important; margin-right: 12px;
padding: 10px; padding: 0;
color: #000000; color: #ffffff;
font-weight: 500;
flex-shrink: 0;
position: relative;
overflow: hidden;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
z-index: 1;
}
} }
.participant-subtitle { // Main content area
font-style: italic; .participant-content {
font-size: 11px !important; flex: 1;
margin: 0; display: flex;
color: var(--ov-text-surface-color); flex-direction: column;
min-width: 0; // Allows text truncation
margin-right: 8px;
} }
// Participant name styling
.participant-name { .participant-name {
font-weight: bold !important; font-weight: 600 !important;
color: var(--ov-text-surface-color); font-size: 14px;
line-height: 1.2;
color: var(--ov-text-primary, #212121);
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Local participant indicator
.local-indicator {
font-size: 10px;
font-weight: 600;
color: var(--ov-primary-color, #1976d2);
background-color: var(--ov-primary-light, #e3f2fd);
padding: 4px 8px;
border-radius: var(--ov-surface-radius);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
border: 1px solid var(--ov-primary-color, #1976d2);
}
} }
// Subtitle styling
.participant-subtitle {
font-style: normal;
font-size: 12px !important;
font-weight: 400;
margin: 0;
color: var(--ov-text-secondary, #757575);
line-height: 1.3;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Status indicators
.status-indicator {
display: inline-flex;
align-items: center;
gap: 3px;
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
// Different colors for different statuses
&.camera-on {
color: var(--ov-success-color, #4caf50);
}
&.camera-off {
color: var(--ov-warning-color, #ff9800);
}
&.microphone-muted {
color: var(--ov-error-color, #d32f2f);
}
}
}
// Action buttons container
.participant-action-buttons { .participant-action-buttons {
display: flex; display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
} }
::ng-deep .participant-action-buttons > *:not(#mute-btn) { // Mute button styling
display: contents; #mute-btn {
width: 32px;
height: 32px;
border-radius: 50%;
color: var(--ov-text-secondary, #757575);
background-color: transparent;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&:hover {
background-color: var(--ov-surface-hover, #f5f5f5);
color: var(--ov-text-primary, #212121);
transform: scale(1.1);
}
&:focus {
outline: 2px solid var(--ov-primary-color, #1976d2);
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
pointer-events: none;
}
&.warn-btn {
color: var(--ov-error-color, #d32f2f);
background-color: var(--ov-error-light, #ffebee);
&:hover {
background-color: var(--ov-error-color, #d32f2f);
color: #ffffff;
}
// Pulsing animation for muted state
animation: pulse 2s infinite;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
} }
::ng-deep .participant-action-buttons > *:not(#mute-btn) > * { // After local participant content area
margin: auto; .after-local-content {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ov-surface-border, #e0e0e0);
animation: fadeIn 0.3s ease-in-out;
background-color: var(--ov-surface-alt, #fafafa);
border-radius: var(--ov-surface-radius, 8px);
padding: 12px;
}
// External item elements styling
.external-elements {
display: flex;
align-items: center;
gap: 4px;
// Custom styling for external buttons
::ng-deep button {
transition: all 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
}
}
// Material Design overrides for better integration
mat-list {
padding: 0;
} }
::ng-deep .mat-mdc-list-item { ::ng-deep .mat-mdc-list-item {
height: max-content !important; height: auto !important;
padding-bottom: 10px !important; padding: 0 !important;
} min-height: auto !important;
border-radius: var(--ov-surface-radius, 8px);
::ng-deep .mat-mdc-list-item:hover {
color: #000000 !important;
}
::ng-deep .mat-mdc-list-item:hover .mat-mdc-list-item-title {
color: var(--ov-text-surface-color) !important;
}
mat-list {
padding: 3px;
} }
::ng-deep .mdc-list-item__content { ::ng-deep .mdc-list-item__content {
padding-left: 10px !important; padding: 0 !important;
align-self: center !important; align-self: stretch !important;
width: 100%;
} }
::ng-deep .mat-mdc-list-base { ::ng-deep .mat-mdc-list-base {
--mdc-list-list-item-hover-label-text-color: unset; --mdc-list-list-item-hover-label-text-color: unset;
--mdc-list-list-item-hover-leading-icon-color: unset; --mdc-list-list-item-hover-leading-icon-color: unset;
padding: 0;
} }
#mute-btn { ::ng-deep .mat-mdc-list-item:hover {
border-radius: 50%; background-color: transparent !important;
color: var(--ov-text-surface-color);
} }
.warn-btn { // Animations
/* background-color: var(--ov-error-color) !important; */ @keyframes fadeIn {
color: var(--ov-error-color); from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
// Responsive design
@media (max-width: 768px) {
.participant-container {
padding: 10px 12px;
min-height: 56px;
}
.participant-avatar {
width: 36px;
height: 36px;
margin-right: 10px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&::after {
width: 10px;
height: 10px;
bottom: 1px;
right: 1px;
}
}
.participant-name {
font-size: 13px;
.local-indicator {
font-size: 9px;
padding: 2px 6px;
}
}
.participant-subtitle {
font-size: 11px !important;
}
#mute-btn {
width: 28px;
height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.after-local-content {
margin-top: 10px;
padding-top: 10px;
padding: 10px;
}
}
// High contrast mode support
@media (prefers-contrast: high) {
.participant-container {
border: 2px solid var(--ov-text-primary, #212121);
}
.participant-avatar {
border: 2px solid var(--ov-surface-background, #ffffff);
}
.local-indicator {
border-width: 2px;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.participant-container,
.participant-avatar,
#mute-btn,
.after-local-content,
.external-elements ::ng-deep button {
transition: none;
animation: none;
}
.participant-container:hover {
transform: none;
}
.participant-avatar:hover,
#mute-btn:hover,
.external-elements ::ng-deep button:hover {
transform: none;
}
#mute-btn.warn-btn {
animation: none;
}
}
// Dark theme support
@media (prefers-color-scheme: dark) {
.participant-container {
background-color: var(--ov-surface-background, #424242);
border-bottom-color: var(--ov-surface-border, #616161);
&:hover {
background-color: var(--ov-surface-hover, #484848);
}
}
.participant-name {
color: var(--ov-text-primary, #ffffff);
}
.participant-subtitle {
color: var(--ov-text-secondary, #cccccc);
}
.after-local-content {
background-color: var(--ov-surface-alt, #373737);
}
} }
} }

View File

@ -1,17 +1,17 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { ParticipantPanelItemElementsDirective } from '../../../../directives/template/openvidu-components-angular.directive'; import { ParticipantPanelItemElementsDirective } from '../../../../directives/template/openvidu-components-angular.directive';
// import { ParticipantPanelAfterLocalParticipantDirective } from '../../../../directives/template/internals.directive';
import { ParticipantModel } from '../../../../models/participant.model'; import { ParticipantModel } from '../../../../models/participant.model';
import { OpenViduComponentsConfigService } from '../../../../services/config/directive-config.service'; import { OpenViduComponentsConfigService } from '../../../../services/config/directive-config.service';
import { ParticipantService } from '../../../../services/participant/participant.service'; import { ParticipantService } from '../../../../services/participant/participant.service';
import { TemplateManagerService, ParticipantPanelItemTemplateConfiguration } from '../../../../services/template/template-manager.service'; import { TemplateManagerService, ParticipantPanelItemTemplateConfiguration } from '../../../../services/template/template-manager.service';
/** /**
*
* The **ParticipantPanelItemComponent** is hosted inside of the {@link ParticipantsPanelComponent}. * The **ParticipantPanelItemComponent** is hosted inside of the {@link ParticipantsPanelComponent}.
* It is in charge of displaying the participants information inside of the ParticipansPanelComponent. * It displays participant information with enhanced UI/UX, including support for custom content
* injection through structural directives.
*/ */
@Component({ @Component({
selector: 'ov-participant-panel-item', selector: 'ov-participant-panel-item',
templateUrl: './participant-panel-item.component.html', templateUrl: './participant-panel-item.component.html',
@ -42,6 +42,17 @@ export class ParticipantPanelItemComponent implements OnInit, OnDestroy {
} }
} }
// /**
// * @ignore
// */
// @ContentChild(ParticipantPanelAfterLocalParticipantDirective)
// set externalAfterLocalParticipant(afterLocalParticipant: ParticipantPanelAfterLocalParticipantDirective) {
// this._externalAfterLocalParticipant = afterLocalParticipant;
// if (afterLocalParticipant) {
// this.updateTemplatesAndMarkForCheck();
// }
// }
/** /**
* @internal * @internal
* Template configuration managed by the service * Template configuration managed by the service
@ -50,21 +61,29 @@ export class ParticipantPanelItemComponent implements OnInit, OnDestroy {
// Store directive references for template setup // Store directive references for template setup
private _externalItemElements?: ParticipantPanelItemElementsDirective; private _externalItemElements?: ParticipantPanelItemElementsDirective;
// private _externalAfterLocalParticipant?: ParticipantPanelAfterLocalParticipantDirective;
/** /**
* The participant to be displayed * The participant to be displayed
* @ignore
*/ */
@Input() @Input()
set participant(participant: ParticipantModel) { set participant(participant: ParticipantModel) {
this._participant = participant; this._participant = participant;
this.cd.markForCheck();
} }
/** /**
* @ignore * @internal
* Current participant being displayed
*/ */
_participant: ParticipantModel; _participant: ParticipantModel;
/**
* Whether to show the mute button for remote participants
*/
@Input()
muteButton: boolean = true;
/** /**
* @ignore * @ignore
*/ */
@ -91,14 +110,49 @@ export class ParticipantPanelItemComponent implements OnInit, OnDestroy {
} }
/** /**
* @ignore * Toggles the mute state of a remote participant
*/ */
toggleMuteForcibly() { toggleMuteForcibly() {
if (this._participant) { if (this._participant && !this._participant.isLocal) {
this.participantService.setRemoteMutedForcibly(this._participant.sid, !this._participant.isMutedForcibly); this.participantService.setRemoteMutedForcibly(this._participant.sid, !this._participant.isMutedForcibly);
} }
} }
/**
* Gets the template for content after local participant
*/
// get afterLocalParticipantTemplate(): TemplateRef<any> | undefined {
// return this._externalAfterLocalParticipant?.template;
// }
/**
* Checks if the current participant is the local participant
*/
get isLocalParticipant(): boolean {
return this._participant?.isLocal || false;
}
/**
* Gets the participant's display name
*/
get participantDisplayName(): string {
return this._participant?.name || '';
}
/**
* Checks if external elements are available
*/
get hasExternalElements(): boolean {
return !!this.participantPanelItemElementsTemplate;
}
/**
* Checks if after local participant content is available
*/
// get hasAfterLocalContent(): boolean {
// return this.isLocalParticipant && !!this.afterLocalParticipantTemplate;
// }
/** /**
* @internal * @internal
* Sets up all templates using the template manager service * Sets up all templates using the template manager service

View File

@ -91,7 +91,9 @@
"MICROPHONE": "麦克风", "MICROPHONE": "麦克风",
"SCREEN": "屏幕", "SCREEN": "屏幕",
"NO_STREAMS": "无", "NO_STREAMS": "无",
"YOU": "你" "YOU": "你",
"MUTE": "静音",
"UNMUTE": "取消静音"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "设置", "TITLE": "设置",

View File

@ -90,7 +90,9 @@
"MICROPHONE": "MIKROFON", "MICROPHONE": "MIKROFON",
"SCREEN": "BILDSCHIRM", "SCREEN": "BILDSCHIRM",
"NO_STREAMS": "KEINE", "NO_STREAMS": "KEINE",
"YOU": "Sie" "YOU": "Sie",
"MUTE": "Stummschalten",
"UNMUTE": "Stummschaltung aufheben"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Einstellungen", "TITLE": "Einstellungen",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICROPHONE", "MICROPHONE": "MICROPHONE",
"SCREEN": "SCREEN", "SCREEN": "SCREEN",
"NO_STREAMS": "NONE", "NO_STREAMS": "NONE",
"YOU": "You" "YOU": "You",
"MUTE": "Mute",
"UNMUTE": "Unmute"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Settings", "TITLE": "Settings",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICRÓFONO", "MICROPHONE": "MICRÓFONO",
"SCREEN": "PANTALLA", "SCREEN": "PANTALLA",
"NO_STREAMS": "NINGUNO", "NO_STREAMS": "NINGUNO",
"YOU": "Tú" "YOU": "Tú",
"MUTE": "Silenciar",
"UNMUTE": "Activar audio"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Configuración", "TITLE": "Configuración",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICROPHONE", "MICROPHONE": "MICROPHONE",
"SCREEN": "ÉCRAN", "SCREEN": "ÉCRAN",
"NO_STREAMS": "PAS_DE_FLUX", "NO_STREAMS": "PAS_DE_FLUX",
"YOU": "Vous" "YOU": "Vous",
"MUTE": "Couper le son",
"UNMUTE": "Désactiver le son"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Paramètres", "TITLE": "Paramètres",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "माइक्रोफ़ोन", "MICROPHONE": "माइक्रोफ़ोन",
"SCREEN": "स्क्रीन", "SCREEN": "स्क्रीन",
"NO_STREAMS": "कोई_स्ट्रीम_नहीं", "NO_STREAMS": "कोई_स्ट्रीम_नहीं",
"YOU": "आप" "YOU": "आप",
"MUTE": "मौन",
"UNMUTE": "अनमौन"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "सेटिंग्स", "TITLE": "सेटिंग्स",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICROFONO", "MICROPHONE": "MICROFONO",
"SCREEN": "SCREEN", "SCREEN": "SCREEN",
"NO_STREAMS": "NESSUNO", "NO_STREAMS": "NESSUNO",
"YOU": "Tu" "YOU": "Tu",
"MUTE": "Disattiva l'audio",
"UNMUTE": "Attiva l'audio"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Impostazioni", "TITLE": "Impostazioni",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "マイクロフォン", "MICROPHONE": "マイクロフォン",
"SCREEN": "スクリーン", "SCREEN": "スクリーン",
"NO_STREAMS": "ストリームなし", "NO_STREAMS": "ストリームなし",
"YOU": "あなた" "YOU": "あなた",
"MUTE": "ミュート",
"UNMUTE": "ミュート解除"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "設定", "TITLE": "設定",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICROFOON", "MICROPHONE": "MICROFOON",
"SCREEN": "SCHERM", "SCREEN": "SCHERM",
"NO_STREAMS": "GEEN", "NO_STREAMS": "GEEN",
"YOU": "Jij" "YOU": "Jij",
"MUTE": "Dempen",
"UNMUTE": "Dempen opheffen"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Instellingen", "TITLE": "Instellingen",

View File

@ -91,7 +91,9 @@
"MICROPHONE": "MICROFONE", "MICROPHONE": "MICROFONE",
"SCREEN": "TELA", "SCREEN": "TELA",
"NO_STREAMS": "NENHUM", "NO_STREAMS": "NENHUM",
"YOU": "Você (eu)" "YOU": "Você (eu)",
"MUTE": "Silenciar",
"UNMUTE": "Ativar som"
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Configurações", "TITLE": "Configurações",