frontend: implement appearance configuration form with theme customization options

frontend: update theme initialization to align with OpenVidu Meet themes

frontend: remove unused color variables and update styles configuration

frontend: enhance theme customization with dynamic color picker and theme loading
This commit is contained in:
Carlos Santos 2025-09-24 17:10:58 +02:00
parent 7978a10948
commit 8de6d127eb
10 changed files with 622 additions and 50 deletions

View File

@ -25,7 +25,7 @@
"tsConfig": "src/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss", "src/colors.scss"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {

View File

@ -1 +1,127 @@
<p>config works!</p>
<div class="ov-page-container ov-mb-xxl">
<div class="page-header">
<div class="title">
<mat-icon class="material-symbols-outlined ov-settings-icon">settings</mat-icon>
<h1>Configuration</h1>
</div>
<p class="subtitle">Customize the look and feel of your OpenVidu Meet rooms.</p>
</div>
@if (isLoading()) {
<div class="ov-page-loading">
<div class="loading-content">
<div class="loading-header">
<div class="loading-title">
<mat-icon class="ov-settings-icon loading-icon">settings</mat-icon>
<h1>Loading theme configuration</h1>
</div>
<p class="loading-subtitle">Please wait while we fetch your theme settings...</p>
</div>
<div class="loading-spinner-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
</div>
</div>
} @else {
<div class="page-content">
<!-- Theme Configuration Section -->
<mat-card class="section-card theme-config-card">
<mat-card-header>
<div mat-card-avatar>
<mat-icon class="section-icon">palette</mat-icon>
</div>
<mat-card-title>Appearance</mat-card-title>
<mat-card-subtitle>Configure custom appearance for your rooms</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="ov-mt-sm">
<form [formGroup]="appearanceForm" class="theme-form">
<!-- Enable/Disable Toggle -->
<h4 class="section-title">Custom Theme</h4>
<div class="theme-toggle ov-mt-xs">
<span>Enable custom theme</span>
<mat-slide-toggle
formControlName="enabled"
[matTooltip]="!isThemeEnabled ? 'Enable custom theme to override default appearance' : ''"
[matTooltipDisabled]="isThemeEnabled"
></mat-slide-toggle>
</div>
<!-- Theme Configuration Form -->
@if (isThemeEnabled) {
<!-- Base theme (light / dark) -->
<div class="theme-section">
<h4 class="section-title">Base Theme</h4>
<p class="section-description ov-mt-xs">
Select the foundation theme for your custom appearance.
</p>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Base Theme</mat-label>
<mat-select formControlName="baseTheme">
@for (theme of baseThemeOptions; track theme) {
<mat-option [value]="theme">
{{ theme.charAt(0).toUpperCase() + theme.slice(1).toLowerCase() }}
</mat-option>
}
</mat-select>
@if (appearanceForm.get('baseTheme')?.hasError('required')) {
<mat-error>Base theme is required</mat-error>
}
</mat-form-field>
</div>
<!-- Color Pickers -->
<div class="theme-section">
<h4 class="section-title">Color Customization</h4>
<p class="section-description ov-mt-xs">
Customize the colors of your theme. Click on any color to modify it.
</p>
<div class="color-picker-grid ov-mt-md">
@for (colorConfig of colorFields; track colorConfig.key) {
<div class="color-picker-item" (click)="focusColorInput(colorConfig.key)">
<span class="color-label">{{ colorConfig.label }}</span>
<div class="color-circle-wrapper">
<input
[id]="colorConfig.key"
type="color"
[formControlName]="colorConfig.key"
[value]="getColorValue(colorConfig.key)"
class="color-input"
/>
<div
class="color-circle"
[style.background-color]="getColorValue(colorConfig.key)"
[class.has-custom-color]="hasCustomColor(colorConfig.key)"
></div>
</div>
</div>
}
</div>
</div>
}
</form>
</mat-card-content>
@if (isThemeEnabled) {
<!-- Action Buttons -->
<mat-card-actions>
<button
mat-button
class="primary-button"
(click)="onSaveAppearanceConfig()"
[disabled]="appearanceForm.invalid || !hasChanges()"
>
<mat-icon>save</mat-icon>
Save Theme Configuration
</button>
<button mat-stroked-button (click)="onResetForm()" [disabled]="!hasChanges()">
<mat-icon>undo</mat-icon>
Reset Changes
</button>
</mat-card-actions>
}
</mat-card>
</div>
}
</div>

View File

@ -0,0 +1,204 @@
@import '../../../../../../../src/assets/styles/design-tokens';
.ov-page-container {
button {
padding: var(--ov-meet-button-padding-vertical) var(--ov-meet-button-padding-horizontal);
}
}
// Theme Configuration Section
.theme-config-card {
.theme-form {
@extend .ov-settings-form-section;
.full-width {
width: 100%;
}
.section-title {
margin: 0;
}
.theme-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--ov-meet-spacing-md) var(--ov-meet-spacing-md) 0;
::ng-deep button {
padding: 0 !important;
}
}
// Input field styling
.mat-mdc-form-field {
margin-bottom: var(--ov-meet-spacing-lg);
::ng-deep .mat-mdc-text-field-wrapper {
background-color: var(--ov-meet-surface-variant);
border-radius: var(--ov-meet-border-radius-sm);
}
::ng-deep .mdc-notched-outline__leading,
::ng-deep .mdc-notched-outline__notch,
::ng-deep .mdc-notched-outline__trailing {
border-color: var(--ov-meet-border-color);
}
}
// Color picker grid layout - responsive and clean
.color-picker-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--ov-meet-spacing-xl);
margin: var(--ov-meet-spacing-lg) 0;
// Tablet and larger - maintain 4 columns
@include ov-tablet-up {
gap: var(--ov-meet-spacing-xxl);
}
// Small tablets - 2 columns
@include ov-tablet-down {
grid-template-columns: repeat(2, 1fr);
gap: var(--ov-meet-spacing-lg);
}
// Mobile - single column
@include ov-mobile-down {
grid-template-columns: 1fr;
gap: var(--ov-meet-spacing-md);
}
}
.color-picker-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ov-meet-spacing-sm);
cursor: pointer;
transition:
transform 0.2s ease,
opacity 0.2s ease;
&:hover {
transform: translateY(-2px);
// opacity: 0.9;
}
&:active {
transform: translateY(0);
}
.color-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--ov-meet-text-color-primary);
text-align: center;
margin-bottom: var(--ov-meet-spacing-xs);
user-select: none;
}
.color-circle-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
.color-input {
position: absolute;
opacity: 0;
width: 80px;
height: 80px;
cursor: pointer;
border: none;
background: none;
z-index: 2;
// &::-webkit-color-swatch-wrapper {
// padding: 0;
// border: none;
// border-radius: 50%;
// }
// &::-webkit-color-swatch {
// border: none;
// border-radius: 50%;
// }
// &::-moz-color-swatch {
// border: none;
// border-radius: 50%;
// }
}
.color-circle {
width: 80px;
height: 80px;
border-radius: var(--ov-meet-radius-lg);
border: 3px solid var(--ov-meet-border-color);
position: relative;
z-index: 1;
}
}
}
// Reset colors section
.reset-colors-section {
display: flex;
justify-content: center;
margin-top: var(--ov-meet-spacing-lg);
padding-top: var(--ov-meet-spacing-lg);
border-top: 1px solid var(--ov-meet-border-color);
.reset-colors-btn {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
font-size: 0.875rem;
color: var(--ov-meet-text-color-secondary);
border-color: var(--ov-meet-border-color);
&:hover {
color: var(--ov-meet-color-error);
border-color: var(--ov-meet-color-error);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
}
}
// Card Actions - responsive button layout
.mat-mdc-card-actions {
padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-xl);
gap: var(--ov-meet-spacing-sm);
border-top: 1px solid var(--ov-meet-border-color);
margin: auto;
display: flex;
flex-wrap: wrap;
@include ov-mobile-down {
flex-direction: column;
.mat-mdc-button,
.mat-mdc-raised-button,
.mat-mdc-stroked-button {
width: 100%;
margin: var(--ov-meet-spacing-xs) 0;
}
}
@include ov-tablet-up {
justify-content: flex-start;
button:first-child {
margin-right: auto;
}
}
}

View File

@ -1,12 +1,246 @@
import { Component } from '@angular/core';
import { Component, OnInit, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { GlobalConfigService, NotificationService } from '@lib/services';
import { MeetAppearanceConfig, MeetRoomTheme, MeetRoomThemeMode } from '@lib/typings/ce';
import {
OPENVIDU_COMPONENTS_DARK_THEME,
OPENVIDU_COMPONENTS_LIGHT_THEME,
OpenViduThemeService
} from 'openvidu-components-angular';
type ColorField = 'backgroundColor' | 'primaryColor' | 'secondaryColor' | 'surfaceColor';
interface ThemeColors {
backgroundColor: string;
primaryColor: string;
secondaryColor: string;
surfaceColor: string;
}
@Component({
selector: 'ov-config',
standalone: true,
imports: [],
templateUrl: './config.component.html',
styleUrl: './config.component.scss'
selector: 'ov-config',
standalone: true,
imports: [
MatCardModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatSelectModule,
MatSlideToggleModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatDividerModule,
ReactiveFormsModule
],
templateUrl: './config.component.html',
styleUrl: './config.component.scss'
})
export class ConfigComponent {
export class ConfigComponent implements OnInit {
isLoading = signal(true);
hasChanges = signal(false);
appearanceForm = new FormGroup({
enabled: new FormControl<boolean>(false, { nonNullable: true }),
baseTheme: new FormControl<MeetRoomThemeMode>(MeetRoomThemeMode.LIGHT, {
validators: [Validators.required],
nonNullable: true
}),
backgroundColor: new FormControl<string>('', { nonNullable: true }),
primaryColor: new FormControl<string>('', { nonNullable: true }),
secondaryColor: new FormControl<string>('', { nonNullable: true }),
surfaceColor: new FormControl<string>('', { nonNullable: true })
});
baseThemeOptions: MeetRoomThemeMode[] = [MeetRoomThemeMode.LIGHT, MeetRoomThemeMode.DARK];
// Color picker configuration
colorFields: Array<{ key: ColorField; label: string }> = [
{ key: 'backgroundColor', label: 'Background' },
{ key: 'primaryColor', label: 'Primary' },
{ key: 'secondaryColor', label: 'Secondary' },
{ key: 'surfaceColor', label: 'Surface' }
];
private initialFormValue: MeetRoomTheme | null = null;
// Default color values based on theme
private readonly defaultColors: Record<MeetRoomThemeMode, ThemeColors> = {
[MeetRoomThemeMode.LIGHT]: {
backgroundColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-background-color'] as string,
primaryColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-primary-action-color'] as string,
secondaryColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-secondary-action-color'] as string,
surfaceColor: OPENVIDU_COMPONENTS_LIGHT_THEME['--ov-surface-color'] as string
},
[MeetRoomThemeMode.DARK]: {
backgroundColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-background-color'] as string,
primaryColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-primary-action-color'] as string,
secondaryColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-secondary-action-color'] as string,
surfaceColor: OPENVIDU_COMPONENTS_DARK_THEME['--ov-surface-color'] as string
}
};
constructor(
private configService: GlobalConfigService,
private notificationService: NotificationService
) {
// Track form changes
this.appearanceForm.valueChanges.subscribe(() => {
this.checkForChanges();
});
}
async ngOnInit() {
this.isLoading.set(true);
try {
await this.loadAppearanceConfig();
} catch (error) {
console.error('Error during component initialization:', error);
this.notificationService.showSnackbar('Failed to initialize theme configuration');
} finally {
this.isLoading.set(false);
}
}
// Form state getters
get isThemeEnabled(): boolean {
return this.appearanceForm.get('enabled')?.value ?? false;
}
// Form actions
onResetForm(): void {
if (this.initialFormValue) {
this.appearanceForm.patchValue(this.initialFormValue);
this.hasChanges.set(false);
}
}
// Color management methods
getColorValue(colorField: ColorField): string {
const formValue = this.appearanceForm.get(colorField)?.value;
if (formValue?.trim()) {
return formValue;
}
const baseTheme = this.appearanceForm.get('baseTheme')?.value || MeetRoomThemeMode.LIGHT;
return this.defaultColors[baseTheme][colorField];
}
focusColorInput(colorField: ColorField): void {
const inputElement = document.getElementById(colorField) as HTMLInputElement;
inputElement?.click();
}
hasCustomColor(colorField: ColorField): boolean {
const formValue = this.appearanceForm.get(colorField)?.value;
return Boolean(formValue?.trim());
}
// Configuration management
private async loadAppearanceConfig(): Promise<void> {
try {
const { appearance } = await this.configService.getRoomsAppearanceConfig();
const themeConfig = appearance?.themes?.[0];
if (themeConfig) {
this.appearanceForm.patchValue({
enabled: themeConfig.enabled,
baseTheme: themeConfig.baseTheme,
backgroundColor: themeConfig.backgroundColor || '',
primaryColor: themeConfig.primaryColor || '',
secondaryColor: themeConfig.secondaryColor || '',
surfaceColor: themeConfig.surfaceColor || ''
});
} else {
// Set default values
this.appearanceForm.patchValue({
enabled: false,
baseTheme: MeetRoomThemeMode.LIGHT,
backgroundColor: '',
primaryColor: '',
secondaryColor: '',
surfaceColor: ''
});
}
this.storeInitialValues();
} catch (error) {
console.error('Error loading appearance config:', error);
this.appearanceForm.patchValue({
enabled: false,
baseTheme: MeetRoomThemeMode.LIGHT,
backgroundColor: '',
primaryColor: '',
secondaryColor: '',
surfaceColor: ''
});
this.storeInitialValues();
throw error;
}
}
private storeInitialValues(): void {
this.initialFormValue = { ...this.appearanceForm.value } as MeetRoomTheme;
this.hasChanges.set(false);
}
private checkForChanges(): void {
if (!this.initialFormValue) {
return;
}
const currentValue = this.appearanceForm.value;
const hasChangesDetected = JSON.stringify(currentValue) !== JSON.stringify(this.initialFormValue);
this.hasChanges.set(hasChangesDetected);
if (!currentValue.enabled) {
this.onSaveAppearanceConfig();
}
}
async onSaveAppearanceConfig(): Promise<void> {
if (this.appearanceForm.invalid) {
this.notificationService.showSnackbar('Please fix form errors before saving');
return;
}
const formData = this.appearanceForm.value;
try {
const appearanceConfig: MeetAppearanceConfig = {
themes: [this.createThemeFromFormData(formData as MeetRoomTheme)]
};
await this.configService.saveRoomsAppearanceConfig(appearanceConfig);
this.notificationService.showSnackbar('Theme configuration saved successfully');
this.storeInitialValues();
} catch (error) {
console.error('Error saving appearance config:', error);
this.notificationService.showSnackbar('Failed to save theme configuration');
}
}
private createThemeFromFormData(formData: MeetRoomTheme): MeetRoomTheme {
const baseTheme = formData.baseTheme ?? MeetRoomThemeMode.LIGHT;
const defaults = this.defaultColors[baseTheme];
return {
enabled: formData.enabled,
name: 'default',
baseTheme,
backgroundColor: formData.backgroundColor?.trim() ? formData.backgroundColor : defaults.backgroundColor,
primaryColor: formData.primaryColor?.trim() ? formData.primaryColor : defaults.primaryColor,
secondaryColor: formData.secondaryColor?.trim() ? formData.secondaryColor : defaults.secondaryColor,
surfaceColor: formData.surfaceColor?.trim() ? formData.surfaceColor : defaults.surfaceColor
};
}
}

View File

@ -31,6 +31,7 @@
[recordingActivityShowRecordingsList]="false"
[activitiesPanelBroadcastingActivity]="false"
[showDisconnectionDialog]="false"
[showThemeSelector]="!features().showThemeSelector"
(onRoomCreated)="onRoomCreated($event)"
(onParticipantConnected)="onParticipantConnected($event)"
(onParticipantLeft)="onParticipantLeft($event)"

View File

@ -20,6 +20,7 @@ import {
ApplicationFeatures,
AuthService,
FeatureConfigurationService,
GlobalConfigService,
MeetingService,
NavigationService,
NotificationService,
@ -49,6 +50,7 @@ import {
LeaveButtonDirective,
OpenViduComponentsUiModule,
OpenViduService,
OpenViduThemeService,
ParticipantLeftEvent,
ParticipantLeftReason,
ParticipantModel,
@ -128,7 +130,9 @@ export class MeetingComponent implements OnInit {
protected navigationService: NavigationService,
protected notificationService: NotificationService,
protected clipboard: Clipboard,
protected viewportService: ViewportService
protected viewportService: ViewportService,
protected ovThemeService: OpenViduThemeService,
protected configService: GlobalConfigService
) {
this.features = this.featureConfService.features;
}
@ -281,6 +285,22 @@ export class MeetingComponent implements OnInit {
await this.roomService.loadRoomConfig(this.roomId);
this.showMeeting = true;
const { appearance } = await this.configService.getRoomsAppearanceConfig();
console.log('Loaded appearance config:', appearance);
if (appearance.themes.length > 0 && appearance.themes[0].enabled) {
const theme = appearance.themes[0];
this.ovThemeService.updateThemeVariables({
'--ov-primary-action-color': theme.primaryColor,
'--ov-secondary-action-color': theme.secondaryColor,
'--ov-background-color': theme.backgroundColor,
'--ov-surface-color': theme.surfaceColor
});
this.features().showThemeSelector = false;
} else {
this.ovThemeService.resetThemeVariables();
this.features().showThemeSelector = true;
}
combineLatest([
this.ovComponentsParticipantService.remoteParticipants$,
this.ovComponentsParticipantService.localParticipant$

View File

@ -26,6 +26,7 @@ export interface ApplicationFeatures {
showParticipantList: boolean;
showSettings: boolean;
showFullscreen: boolean;
showThemeSelector: boolean;
// Permissions
canModerateRoom: boolean;
@ -49,6 +50,7 @@ const DEFAULT_FEATURES: ApplicationFeatures = {
showParticipantList: true,
showSettings: true,
showFullscreen: true,
showThemeSelector: true,
canModerateRoom: false,
canRecordRoom: false,

View File

@ -28,6 +28,10 @@ export class ThemeService {
* 3. Light theme as default
*/
initializeTheme(): void {
// Override available themes in OpenVidu Components to match OpenVidu Meet themes.
// OpenVidu Meet users do not know nothing about "classic" theme.
this.ovComponentsThemeService.getAllThemes = () => [OpenViduThemeMode.Light, OpenViduThemeMode.Dark];
const savedTheme = this.getSavedTheme();
const systemPreference = this.getSystemPreference();
const initialTheme = savedTheme || systemPreference || 'light';

View File

@ -1,20 +0,0 @@
// OpenVidu Components Color Variables
:root {
--ov-background-color: #1f2020;
--ov-surface-color: #ffffff;
--ov-primary-action-color: #273235;
--ov-secondary-action-color: #f1f1f1;
--ov-accent-action-color: #0089ab;
--ov-error-color: #eb5144;
--ov-warn-color: #ffba53;
--ov-text-primary-color: #ffffff;
--ov-text-surface-color: #1d1d1d;
--ov-toolbar-buttons-radius: 50%;
--ov-leave-button-radius: 10px;
--ov-video-radius: 5px;
--ov-surface-radius: 5px;
}

View File

@ -2,48 +2,49 @@
* Interface representing the config for a room.
*/
export interface MeetRoomConfig {
chat: MeetChatConfig;
recording: MeetRecordingConfig;
virtualBackground: MeetVirtualBackgroundConfig;
// appearance?: MeetAppearanceConfig;
chat: MeetChatConfig;
recording: MeetRecordingConfig;
virtualBackground: MeetVirtualBackgroundConfig;
// appearance?: MeetAppearanceConfig;
}
/**
* Interface representing the config for recordings in a room.
*/
export interface MeetRecordingConfig {
enabled: boolean;
allowAccessTo?: MeetRecordingAccess;
enabled: boolean;
allowAccessTo?: MeetRecordingAccess;
}
export const enum MeetRecordingAccess {
ADMIN = 'admin', // Only admins can access the recording
ADMIN_MODERATOR = 'admin_moderator', // Admins and moderators can access
ADMIN_MODERATOR_SPEAKER = 'admin_moderator_speaker' // Admins, moderators and speakers can access
ADMIN = 'admin', // Only admins can access the recording
ADMIN_MODERATOR = 'admin_moderator', // Admins and moderators can access
ADMIN_MODERATOR_SPEAKER = 'admin_moderator_speaker', // Admins, moderators and speakers can access
}
export interface MeetChatConfig {
enabled: boolean;
enabled: boolean;
}
export interface MeetVirtualBackgroundConfig {
enabled: boolean;
enabled: boolean;
}
export interface MeetAppearanceConfig {
themes: MeetRoomTheme[];
themes: MeetRoomTheme[];
}
export interface MeetRoomTheme {
name: string;
baseTheme: MeetRoomThemeMode;
backgroundColor?: string;
primaryColor?: string;
secondaryColor?: string;
surfaceColor?: string;
enabled: boolean;
name: string;
baseTheme: MeetRoomThemeMode;
backgroundColor?: string;
primaryColor?: string;
secondaryColor?: string;
surfaceColor?: string;
}
export const enum MeetRoomThemeMode {
LIGHT = 'light',
DARK = 'dark'
LIGHT = 'light',
DARK = 'dark',
}