frontend: add meeting settings panel and more options buttons for layout configuration

This commit is contained in:
Carlos Santos 2025-11-24 17:52:22 +01:00
parent b177b3b02e
commit c8cfb6598e
16 changed files with 432 additions and 15 deletions

View File

@ -2,3 +2,5 @@ export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
export * from './meeting-participant-panel-item/meeting-participant-panel-item.component';
export * from './meeting-layout/meeting-layout.component';
export * from './meeting-settings-panel/meeting-settings-panel.component';
export * from './meeting-toolbar-more-options-buttons/meeting-toolbar-more-options-buttons.component';

View File

@ -92,7 +92,7 @@ describe('MeetingLayoutComponent', () => {
describe('Default Layout Mode', () => {
beforeEach(() => {
fixture.detectChanges();
layoutModeSubject.next(MeetLayoutMode.DEFAULT);
layoutModeSubject.next(MeetLayoutMode.MOSAIC);
});
it('should show all remote participants in DEFAULT mode', () => {
@ -121,7 +121,7 @@ describe('MeetingLayoutComponent', () => {
describe('Last Speakers Layout Mode', () => {
beforeEach(() => {
fixture.detectChanges();
layoutModeSubject.next(MeetLayoutMode.LAST_SPEAKERS);
layoutModeSubject.next(MeetLayoutMode.SMART_MOSAIC);
fixture.detectChanges();
});
@ -238,7 +238,7 @@ describe('MeetingLayoutComponent', () => {
describe('Participant Cleanup', () => {
it('should remove disconnected participants from active speakers', () => {
fixture.detectChanges();
layoutModeSubject.next(MeetLayoutMode.LAST_SPEAKERS);
layoutModeSubject.next(MeetLayoutMode.SMART_MOSAIC);
fixture.detectChanges();
const mockParticipants = createMockParticipants(5);
@ -274,7 +274,7 @@ describe('MeetingLayoutComponent', () => {
describe('Performance Optimizations', () => {
it('should not process events in DEFAULT mode', () => {
fixture.detectChanges();
layoutModeSubject.next(MeetLayoutMode.DEFAULT);
layoutModeSubject.next(MeetLayoutMode.MOSAIC);
fixture.detectChanges();
const mockParticipants = createMockParticipants(5);
@ -294,7 +294,7 @@ describe('MeetingLayoutComponent', () => {
it('should not process empty speaker arrays', () => {
fixture.detectChanges();
layoutModeSubject.next(MeetLayoutMode.LAST_SPEAKERS);
layoutModeSubject.next(MeetLayoutMode.SMART_MOSAIC);
fixture.detectChanges();
const mockParticipants = createMockParticipants(5);

View File

@ -52,7 +52,7 @@ export class MeetingLayoutComponent {
private readonly remoteParticipants = computed(() => this.meetingContextService.remoteParticipants());
private readonly layoutMode = toSignal(this.layoutService.layoutMode$, {
initialValue: MeetLayoutMode.LAST_SPEAKERS
initialValue: MeetLayoutMode.SMART_MOSAIC
});
/**
@ -64,7 +64,7 @@ export class MeetingLayoutComponent {
/**
* Computed signal that determines if last speakers layout is enabled
*/
private readonly isLastSpeakersLayoutEnabled = computed(() => this.layoutMode() === MeetLayoutMode.LAST_SPEAKERS);
private readonly isLastSpeakersLayoutEnabled = computed(() => this.layoutMode() === MeetLayoutMode.SMART_MOSAIC);
/**
* Computed signal that provides the filtered list of participants to display.

View File

@ -0,0 +1,57 @@
<!-- Grid Layout Configuration Section -->
<div class="layout-section">
<div class="section-header">
<mat-icon class="section-icon material-symbols-outlined">browse</mat-icon>
<h4 class="section-title">Layout settings</h4>
</div>
<!-- Layout Mode Selection -->
<div class="layout-mode-container">
<mat-radio-group
class="layout-radio-group"
[(ngModel)]="layoutMode"
(ngModelChange)="onLayoutModeChange($event)"
aria-label="Select layout mode"
>
<mat-radio-button [value]="LayoutMode.MOSAIC" class="layout-radio-option">
<div class="radio-content">
<span class="radio-label">Mosaic</span>
<span class="radio-description">Shows all participants in a unified grid</span>
</div>
</mat-radio-button>
<mat-radio-button [value]="LayoutMode.SMART_MOSAIC" class="layout-radio-option">
<div class="radio-content">
<span class="radio-label">Smart Mosaic</span>
<span class="radio-description">Shows a limited number of active participants</span>
</div>
</mat-radio-button>
</mat-radio-group>
</div>
<!-- Participant Count Selection (only visible in Smart mode) -->
@if (isSmartMode()) {
<div class="participant-count-container">
<mat-label class="input-label">Number of visible participants</mat-label>
<p class="helper-text">Choose how many remote participants are shown in the grid</p>
<div class="slider-container">
<mat-slider
class="participant-slider"
[min]="1"
[max]="6"
[step]="1"
[displayWith]="formatLabel"
discrete
>
<input
matSliderThumb
[(ngModel)]="participantCount"
(ngModelChange)="onParticipantCountChange($event)"
aria-label="Number of participants to display"
/>
</mat-slider>
<span class="participant-count-value">{{ participantCount() }}</span>
</div>
</div>
}
</div>

View File

@ -0,0 +1,154 @@
.layout-section {
padding: 0;
margin: 0;
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
padding: 0 4px;
.section-icon {
color: var(--ov-text-surface-color);
font-size: 24px;
width: 24px;
height: 24px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--ov-text-surface-color);
}
}
.layout-mode-container {
.input-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--ov-text-surface-color);
margin-bottom: 12px;
padding: 0 4px;
}
.layout-radio-group {
display: flex;
flex-direction: column;
.layout-radio-option {
padding: 12px;
border-radius: 8px;
transition: all 0.2s ease;
margin: 0;
.radio-content {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 8px;
.radio-label {
font-size: 14px;
font-weight: 500;
color: var(--ov-text-surface-color);
}
.radio-description {
font-size: 12px;
color: var(--ov-text-secondary-color);
line-height: 1.4;
}
}
}
}
}
.participant-count-container {
padding: 16px;
border-radius: var(--ov-surface-radius);
border: 1px solid var(--ov-border-color);
.input-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--ov-text-surface-color);
margin-bottom: 16px;
}
.slider-container {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
.participant-slider {
flex: 1;
--mdc-slider-active-track-color: var(--ov-accent-action-color);
--mdc-slider-active-track-shape: 9999px;
--mdc-slider-disabled-active-track-color: var(--ov-accent-action-color);
--mdc-slider-disabled-handle-color: var(--ov-accent-action-color);
--mdc-slider-disabled-inactive-track-color: rgba(var(--ov-accent-action-color), 0.2);
--mdc-slider-focus-handle-color: var(--ov-accent-action-color);
--mdc-slider-handle-color: var(--ov-accent-action-color);
--mdc-slider-hover-handle-color: var(--ov-accent-action-color);
--mdc-slider-inactive-track-color: rgba(var(--ov-accent-action-color), 0.2);
--mdc-slider-inactive-track-shape: 9999px;
--mdc-slider-label-container-color: var(--ov-accent-action-color);
--mdc-slider-with-overlap-handle-outline-color: var(--ov-accent-action-color);
--mdc-slider-with-tick-marks-active-container-color: white;
--mdc-slider-with-tick-marks-disabled-container-color: white;
--mdc-slider-with-tick-marks-inactive-container-color: rgba(var(--ov-accent-action-color), 0.5);
}
.participant-count-value {
min-width: 32px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--ov-text-surface-color);
padding: 6px 10px;
background-color: var(--ov-accent-action-color);
border-radius: var(--ov-surface-radius);
}
}
.helper-text {
margin: 0;
font-size: 12px;
color: var(--ov-text-secondary-color);
line-height: 1.4;
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.layout-section {
.participant-count-container {
padding: 12px;
.slider-container {
.participant-count-value {
min-width: 28px;
font-size: 14px;
padding: 4px 8px;
}
}
}
}
}
// Custom radio button styles for this component
.layout-radio-option {
::ng-deep .mdc-radio {
&:hover {
.mdc-radio__outer-circle {
border-color: none;
}
}
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MeetingSettingsPanelComponent } from './meeting-settings-panel.component';
describe('MeetingSettingsPanelComponent', () => {
let component: MeetingSettingsPanelComponent;
let fixture: ComponentFixture<MeetingSettingsPanelComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MeetingSettingsPanelComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MeetingSettingsPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,79 @@
import { Component, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatRadioModule } from '@angular/material/radio';
import { MatSliderModule } from '@angular/material/slider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
import { MeetLayoutMode } from '../../../models/layout.model';
/**
* Component for additional settings in the Settings Panel.
* This component allows users to configure grid layout preferences including:
* - Layout mode (Mosaic or Mosaic Smart)
* - Number of participants to display in Smart mode
*/
@Component({
selector: 'ov-meeting-settings-panel',
imports: [
CommonModule,
MatIconModule,
MatListModule,
MatRadioModule,
MatSliderModule,
MatFormFieldModule,
MatSelectModule,
FormsModule
],
templateUrl: './meeting-settings-panel.component.html',
styleUrl: './meeting-settings-panel.component.scss'
})
export class MeetingSettingsPanelComponent {
/**
* Expose LayoutMode enum to template
*/
readonly LayoutMode = MeetLayoutMode;
/**
* Current selected layout mode
*/
layoutMode = signal<MeetLayoutMode>(MeetLayoutMode.MOSAIC);
/**
* Number of participants to display in Smart mode
* Range: 1-20
*/
participantCount = signal<number>(6);
/**
* Computed property to check if Smart mode is active
*/
isSmartMode = computed(() => this.layoutMode() === MeetLayoutMode.SMART_MOSAIC);
/**
* Handler for layout mode change
*/
onLayoutModeChange(mode: MeetLayoutMode): void {
this.layoutMode.set(mode);
console.log('Layout mode changed to:', mode);
// TODO: Integrate with layout service when available
}
/**
* Handler for participant count change
*/
onParticipantCountChange(count: number): void {
this.participantCount.set(count);
console.log('Participant count changed to:', count);
// TODO: Integrate with layout service when available
}
/**
* Format label for the participant count slider
*/
formatLabel(value: number): string {
return `${value}`;
}
}

View File

@ -0,0 +1,10 @@
<!-- Grid Layout Settings Button -->
<button
mat-menu-item
id="grid-layout-settings-btn"
(click)="onOpenSettings()"
[matTooltip]="isMobileView() ? '' : 'Configure grid layout'"
>
<mat-icon class="material-symbols-outlined">browser</mat-icon>
<span>Adjust Layout</span>
</button>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MeetingToolbarMoreOptionsButtonsComponent } from './meeting-toolbar-more-options-buttons.component';
describe('MeetingToolbarMoreOptionsButtonsComponent', () => {
let component: MeetingToolbarMoreOptionsButtonsComponent;
let fixture: ComponentFixture<MeetingToolbarMoreOptionsButtonsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MeetingToolbarMoreOptionsButtonsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MeetingToolbarMoreOptionsButtonsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,53 @@
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PanelService, ViewportService, PanelType } from 'openvidu-components-angular';
/**
* Component for additional menu items in the toolbar's "More Options" menu.
* This component handles custom actions like opening the settings panel for grid layout changes.
* It follows the responsive pattern from openvidu-components-angular, adapting to mobile/tablet/desktop views.
*/
@Component({
selector: 'ov-meeting-toolbar-more-options-buttons',
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatMenuModule,
MatTooltipModule
],
templateUrl: './meeting-toolbar-more-options-buttons.component.html',
styleUrl: './meeting-toolbar-more-options-buttons.component.scss'
})
export class MeetingToolbarMoreOptionsButtonsComponent {
/**
* Viewport service for responsive behavior detection
* Injected from openvidu-components-angular
*/
private viewportService = inject(ViewportService);
/**
* Panel service for opening/closing panels
* Injected from openvidu-components-angular
*/
private panelService = inject(PanelService);
/**
* Computed properties for responsive button behavior
* These follow the same pattern as toolbar-media-buttons component
*/
readonly isMobileView = computed(() => this.viewportService.isMobile());
readonly isTabletView = computed(() => this.viewportService.isTablet());
readonly isDesktopView = computed(() => this.viewportService.isDesktop());
/**
* Opens the settings panel to allow users to change grid layout
*/
onOpenSettings(): void {
this.panelService.togglePanel(PanelType.SETTINGS);
}
}

View File

@ -2,11 +2,11 @@ export enum MeetLayoutMode {
/**
* Default layout mode shows all participants in a grid
*/
DEFAULT = 'DEFAULT',
MOSAIC = 'MOSAIC',
/**
* The layout mode that shows the last (x) speakers in a grid.
* The layout mode that shows the last (N) speakers in a grid.
* Optimized for large meetings.
*/
LAST_SPEAKERS = 'LAST_SPEAKERS'
SMART_MOSAIC = 'SMART_MOSAIC'
}

View File

@ -60,6 +60,11 @@
<ng-content select="ov-meeting-toolbar-buttons[slot='additional-buttons']"></ng-content>
</div>
<!-- Toolbar More Options Additional Items -->
<ng-container *ovToolbarMoreOptionsAdditionalMenuItems>
<ng-content select="ov-meeting-toolbar-more-options-buttons[slot='additional-buttons']"></ng-content>
</ng-container>
<!-- Share Link Panel After Local Participant -->
<div *ovParticipantPanelAfterLocalParticipant>
<ng-content select="ov-meeting-share-link-panel[slot='after-local-participant']"></ng-content>
@ -74,6 +79,10 @@
</ng-container>
</div>
<ng-container *ovSettingsPanelGeneralAdditionalElements>
<ng-content select="ov-meeting-settings-panel[slot='additional-general-elements']"></ng-content>
</ng-container>
<!-- Custom layout component -->
<ng-container *ovLayout>
<ng-content select="ov-meeting-layout[slot='layout']"></ng-content>

View File

@ -8,7 +8,7 @@ import { MeetStorageService } from './storage.service';
providedIn: 'root'
})
export class MeetLayoutService extends LayoutService {
private layoutMode: MeetLayoutMode = MeetLayoutMode.DEFAULT;
private layoutMode: MeetLayoutMode = MeetLayoutMode.MOSAIC;
layoutModeSubject: Subject<MeetLayoutMode> = new Subject<MeetLayoutMode>();
layoutMode$: Observable<MeetLayoutMode> = this.layoutModeSubject.asObservable();
@ -35,7 +35,7 @@ export class MeetLayoutService extends LayoutService {
if (layoutMode && Object.values(MeetLayoutMode).includes(layoutMode)) {
this.layoutMode = layoutMode;
} else {
this.layoutMode = MeetLayoutMode.DEFAULT;
this.layoutMode = MeetLayoutMode.MOSAIC;
}
}
@ -45,7 +45,7 @@ export class MeetLayoutService extends LayoutService {
* @returns {boolean} `true` if the layout mode is set to `LAST_SPEAKERS`, otherwise `false`.
*/
isLastSpeakersLayoutEnabled(): boolean {
return this.layoutMode === MeetLayoutMode.LAST_SPEAKERS;
return this.layoutMode === MeetLayoutMode.SMART_MOSAIC;
}
setLayoutMode(layoutMode: MeetLayoutMode) {

View File

@ -1,6 +1,9 @@
<ov-meeting>
<ov-meeting-toolbar-buttons slot="additional-buttons"></ov-meeting-toolbar-buttons>
<ov-meeting-toolbar-more-options-buttons slot="additional-buttons"></ov-meeting-toolbar-more-options-buttons>
<ov-meeting-share-link-panel slot="after-local-participant"></ov-meeting-share-link-panel>
<ov-meeting-participant-panel-item slot="participant-panel-item"></ov-meeting-participant-panel-item>
<ov-meeting-settings-panel slot="additional-general-elements"></ov-meeting-settings-panel>
<ov-meeting-layout slot="layout"></ov-meeting-layout>
</ov-meeting>

View File

@ -4,7 +4,9 @@ import {
MeetingLayoutComponent,
MeetingParticipantPanelItemComponent,
MeetingShareLinkPanelComponent,
MeetingToolbarButtonsComponent
MeetingToolbarButtonsComponent,
MeetingSettingsPanelComponent,
MeetingToolbarMoreOptionsButtonsComponent
} from '@openvidu-meet/shared-components';
@Component({
@ -14,7 +16,9 @@ import {
MeetingToolbarButtonsComponent,
MeetingShareLinkPanelComponent,
MeetingParticipantPanelItemComponent,
MeetingLayoutComponent
MeetingLayoutComponent,
MeetingToolbarMoreOptionsButtonsComponent,
MeetingSettingsPanelComponent
],
templateUrl: './app-ce-meeting.component.html',
styleUrl: './app-ce-meeting.component.scss'