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-item>
<div matListItemIcon class="participant-avatar" [style.background-color]="_participant.colorProfile">
<!-- Main participant container with improved structure -->
<div class="participant-container" [attr.data-participant-id]="_participant?.sid">
<!-- Avatar section with dynamic color -->
<div
class="participant-avatar"
[style.background-color]="_participant?.colorProfile"
[attr.aria-label]="'Avatar for ' + participantDisplayName"
>
<mat-icon>person</mat-icon>
</div>
<h3 matListItemTitle class="participant-name">{{ _participant.name }}
<span *ngIf="_participant.isLocal"> ({{ 'PANEL.PARTICIPANTS.YOU' | translate }})</span>
</h3>
<p matListItemLine class="participant-subtitle">{{ _participant | tracksPublishedTypes }}</p>
<!-- <p matListItemLine>
<span class="participant-subtitle"></span>
</p> -->
<div class="participant-action-buttons" matListItemMeta>
<!-- Content section with name and status -->
<div class="participant-content">
<div class="participant-name">
{{ 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="!_participant.isLocal && showMuteButton"
[class.warn-btn]="_participant.isMutedForcibly"
*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>
<mat-icon *ngIf="!_participant?.isMutedForcibly">volume_up</mat-icon>
<mat-icon *ngIf="_participant?.isMutedForcibly">volume_off</mat-icon>
</button>
<!-- External item elements -->
<ng-container *ngIf="participantPanelItemElementsTemplate">
<!-- External item elements with improved structure -->
<div class="external-elements" *ngIf="hasExternalElements">
<ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container>
</ng-container>
</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>

View File

@ -1,68 +1,419 @@
: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 {
display: inherit;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ov-surface-radius);
margin: auto !important;
padding: 10px;
color: #000000;
margin-right: 12px;
padding: 0;
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 {
font-style: italic;
font-size: 11px !important;
margin: 0;
color: var(--ov-text-surface-color);
// Main content area
.participant-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Allows text truncation
margin-right: 8px;
}
// Participant name styling
.participant-name {
font-weight: bold !important;
color: var(--ov-text-surface-color);
font-weight: 600 !important;
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 {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
::ng-deep .participant-action-buttons > *:not(#mute-btn) {
display: contents;
// Mute button styling
#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);
}
::ng-deep .participant-action-buttons > *:not(#mute-btn) > * {
margin: auto;
&: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;
}
}
// After local participant content area
.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 {
height: max-content !important;
padding-bottom: 10px !important;
}
::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;
height: auto !important;
padding: 0 !important;
min-height: auto !important;
border-radius: var(--ov-surface-radius, 8px);
}
::ng-deep .mdc-list-item__content {
padding-left: 10px !important;
align-self: center !important;
padding: 0 !important;
align-self: stretch !important;
width: 100%;
}
::ng-deep .mat-mdc-list-base {
--mdc-list-list-item-hover-label-text-color: unset;
--mdc-list-list-item-hover-leading-icon-color: unset;
padding: 0;
}
::ng-deep .mat-mdc-list-item:hover {
background-color: transparent !important;
}
// Animations
@keyframes fadeIn {
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 {
border-radius: 50%;
color: var(--ov-text-surface-color);
width: 28px;
height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.warn-btn {
/* background-color: var(--ov-error-color) !important; */
color: var(--ov-error-color);
.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 { Subscription } from 'rxjs';
import { ParticipantPanelItemElementsDirective } from '../../../../directives/template/openvidu-components-angular.directive';
// import { ParticipantPanelAfterLocalParticipantDirective } from '../../../../directives/template/internals.directive';
import { ParticipantModel } from '../../../../models/participant.model';
import { OpenViduComponentsConfigService } from '../../../../services/config/directive-config.service';
import { ParticipantService } from '../../../../services/participant/participant.service';
import { TemplateManagerService, ParticipantPanelItemTemplateConfiguration } from '../../../../services/template/template-manager.service';
/**
*
* 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({
selector: 'ov-participant-panel-item',
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
* Template configuration managed by the service
@ -50,21 +61,29 @@ export class ParticipantPanelItemComponent implements OnInit, OnDestroy {
// Store directive references for template setup
private _externalItemElements?: ParticipantPanelItemElementsDirective;
// private _externalAfterLocalParticipant?: ParticipantPanelAfterLocalParticipantDirective;
/**
* The participant to be displayed
* @ignore
*/
@Input()
set participant(participant: ParticipantModel) {
this._participant = participant;
this.cd.markForCheck();
}
/**
* @ignore
* @internal
* Current participant being displayed
*/
_participant: ParticipantModel;
/**
* Whether to show the mute button for remote participants
*/
@Input()
muteButton: boolean = true;
/**
* @ignore
*/
@ -91,14 +110,49 @@ export class ParticipantPanelItemComponent implements OnInit, OnDestroy {
}
/**
* @ignore
* Toggles the mute state of a remote participant
*/
toggleMuteForcibly() {
if (this._participant) {
if (this._participant && !this._participant.isLocal) {
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
* Sets up all templates using the template manager service

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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