ov-components: add theme selector component and integrate theme management

- Introduced a new theme selector component to allow users to select themes.
- Updated settings panel to include the theme selector.
- Created shared styles for device and theme selectors.
- Refactored existing audio and video device components to use shared styles.
- Enhanced storage service to manage theme preferences.
- Updated theme service to support classic theme alongside light and dark themes.
- Added translations for theme-related strings in multiple languages.
master
Carlos Santos 2025-09-22 17:57:04 +02:00
parent 776c45be3a
commit 9d17c14bcd
23 changed files with 259 additions and 183 deletions

View File

@ -82,6 +82,15 @@
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
</div> </div>
<div class="theme-section">
<mat-list>
<mat-list-item class="theme-selector">
<mat-icon matListItemIcon class="material-symbols-outlined">routine</mat-icon>
<div matListItemTitle>{{ 'PANEL.SETTINGS.THEME' | translate }}</div>
<ov-theme-selector matListItemMeta></ov-theme-selector>
</mat-list-item>
</mat-list>
</div>
</div> </div>
<div *ngIf="showCameraButton && selectedOption === settingsOptions.VIDEO" class="video-settings"> <div *ngIf="showCameraButton && selectedOption === settingsOptions.VIDEO" class="video-settings">
<ov-video-devices-select <ov-video-devices-select

View File

@ -222,7 +222,19 @@
color: var(--ov-text-surface-color) !important; color: var(--ov-text-surface-color) !important;
} }
} }
::ng-deep .mat-mdc-list-item-meta.mdc-list-item__end {
margin: 0 !important;
}
::ng-deep .lang-selector .expand-more-icon,
::ng-deep .lang-selector mat-icon,
::ng-deep .theme-selector .expand-more-icon,
::ng-deep .theme-selector mat-icon {
color: var(--ov-text-surface-color) !important;
}
::ng-deep .lang-selector div,
::ng-deep .theme-selector div,
.input-label { .input-label {
color: var(--ov-text-surface-color) !important; color: var(--ov-text-surface-color) !important;
} }

View File

@ -1,4 +1,4 @@
@use '../device-selector-shared' as shared; @use '../selector-shared' as shared;
:host { :host {
display: flex; display: flex;

View File

@ -1,3 +1,5 @@
@use '../selector-shared' as shared;
:host { :host {
display: inline-block; display: inline-block;
@ -20,92 +22,11 @@
} }
.full-lang-button { .full-lang-button {
display: flex; @include shared.selector-button('lang-icon', 'lang-name', 'expand-icon');
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--ov-surface-color, #ffffff);
border: 2px solid var(--ov-border-color, #e0e0e0);
border-radius: 12px;
transition: all 0.2s ease;
color: var(--ov-text-primary-color, #333);
font-weight: 500;
&:hover {
border-color: var(--ov-primary-action-color, #4285f4);
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.1);
}
.lang-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-surface-color, #666);
}
.lang-name {
font-size: 14px;
font-weight: 500;
display: inline-block !important;
color: var(--ov-text-surface-color) !important;
}
.expand-icon {
font-size: 16px;
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
color: var(--ov-text-secondary-color, #666);
transition: transform 0.2s ease;
vertical-align: bottom;
}
&[aria-expanded='true'] .expand-icon {
transform: rotate(180deg);
}
} }
} }
} }
::ng-deep .language-menu.mat-mdc-menu-panel { ::ng-deep .language-menu.mat-mdc-menu-panel {
border-radius: 12px; @include shared.selector-menu('language-option', 'lang-option-name');
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--ov-border-color);
overflow: hidden;
background: var(--ov-surface-color);
.language-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
transition: background-color 0.2s ease;
font-size: 14px;
min-height: 48px;
color: var(--ov-text-surface-color);
&:hover {
background-color: var(--ov-hover-color);
}
&.selected {
background-color: var(--ov-active-color);
.check-icon {
color: var(--ov-text-surface-color);
}
}
.check-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.lang-option-name {
flex: 1;
font-weight: 500;
}
}
} }

View File

@ -244,3 +244,101 @@
} }
} }
} }
// Selector button mixin
@mixin selector-button($icon-class, $name-class, $expand-class) {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--ov-surface-color, #ffffff);
border: 2px solid var(--ov-border-color, #e0e0e0);
border-radius: 12px;
transition: all 0.2s ease;
color: var(--ov-text-primary-color, #333);
font-weight: 500;
min-width: 150px;
.#{$name-class} {
text-transform: capitalize;
}
&:hover {
border-color: var(--ov-primary-action-color, #4285f4);
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.1);
}
.#{$icon-class} {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-surface-color, #666);
}
.#{$name-class} {
font-size: 14px;
font-weight: 500;
display: inline-block !important;
color: var(--ov-text-surface-color) !important;
}
.#{$expand-class} {
font-size: 16px;
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
color: var(--ov-text-secondary-color, #666);
transition: transform 0.2s ease;
vertical-align: bottom;
}
&[aria-expanded='true'] .#{$expand-class} {
transform: rotate(180deg);
}
}
// selector menu mixin
@mixin selector-menu($option-class, $option-name-class) {
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--ov-border-color);
overflow: hidden;
background: var(--ov-surface-color);
.#{$option-class} {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
transition: background-color 0.2s ease;
font-size: 14px;
min-height: 48px;
color: var(--ov-text-surface-color);
&:hover {
background-color: var(--ov-hover-color);
}
&.selected {
background-color: var(--ov-active-color);
.check-icon {
color: var(--ov-text-surface-color);
}
}
.check-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.#{$option-name-class} {
text-transform: capitalize;
flex: 1;
font-weight: 500;
}
}
}

View File

@ -0,0 +1,14 @@
@use '../selector-shared' as shared;
:host {
display: inline-block;
.theme-selector-container {
.theme-selector-button {
@include shared.selector-button('theme-icon', 'theme-name', 'expand-icon');
}
}
}
::ng-deep .theme-menu.mat-mdc-menu-panel {
@include shared.selector-menu('theme-option', 'theme-option-name');
}

View File

@ -0,0 +1,55 @@
import { Component } from '@angular/core';
import { OpenViduThemeService } from '../../../services/theme/theme.service';
import { OpenViduThemeMode } from '../../../models/theme.model';
@Component({
selector: 'ov-theme-selector',
standalone: false,
template: `
<div class="theme-selector-container">
<button
mat-flat-button
[matMenuTriggerFor]="themeMenu"
aria-haspopup="true"
aria-label="Select theme"
class="theme-selector-button"
>
<span class="theme-name">
{{ currentTheme || 'Select theme' }}
<mat-icon class="expand-icon">expand_more</mat-icon>
</span>
</button>
<!-- Theme selection menu -->
<mat-menu #themeMenu="matMenu" class="theme-menu">
@for (theme of predefinedThemes; track theme) {
<button
mat-menu-item
(click)="setTheme(theme)"
[attr.id]="'theme-' + theme"
[class.selected]="currentTheme === theme"
class="theme-option"
>
@if (currentTheme === theme) {
<mat-icon class="check-icon">check</mat-icon>
}
<span class="theme-option-name">{{ theme }}</span>
</button>
}
</mat-menu>
</div>
`,
styleUrl: './theme-selector.component.scss'
})
export class ThemeSelectorComponent {
protected predefinedThemes: OpenViduThemeMode[] = Object.values(OpenViduThemeMode);
constructor(private themeService: OpenViduThemeService) {}
get currentTheme() {
return this.themeService.getCurrentTheme();
}
setTheme(theme: OpenViduThemeMode) {
this.themeService.setTheme(theme);
}
}

View File

@ -1,4 +1,4 @@
@use '../device-selector-shared' as shared; @use '../selector-shared' as shared;
:host { :host {
display: flex; display: flex;

View File

@ -106,7 +106,8 @@
"CAPTIONS": "字幕", "CAPTIONS": "字幕",
"DISABLED_AUDIO": "没有音频设备", "DISABLED_AUDIO": "没有音频设备",
"DISABLED_VIDEO": "没有视频设备", "DISABLED_VIDEO": "没有视频设备",
"CAPTIONS_LANG_TEXT": "选择房间参与者将使用的语言。字幕将以该语言显示。" "CAPTIONS_LANG_TEXT": "选择房间参与者将使用的语言。字幕将以该语言显示。",
"THEME": "主题"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "背景效果", "TITLE": "背景效果",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Untertitel", "CAPTIONS": "Untertitel",
"DISABLED_AUDIO": "Audio deaktiviert", "DISABLED_AUDIO": "Audio deaktiviert",
"DISABLED_VIDEO": "Video deaktiviert", "DISABLED_VIDEO": "Video deaktiviert",
"CAPTIONS_LANG_TEXT": "Wählen Sie die Sprache, die die Teilnehmer der Raum verwenden. Die Untertitel werden in dieser Sprache angezeigt." "CAPTIONS_LANG_TEXT": "Wählen Sie die Sprache, die die Teilnehmer der Raum verwenden. Die Untertitel werden in dieser Sprache angezeigt.",
"THEME": "Thema"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Hintergrund-Effekte", "TITLE": "Hintergrund-Effekte",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Captions", "CAPTIONS": "Captions",
"DISABLED_AUDIO": "Audio disabled", "DISABLED_AUDIO": "Audio disabled",
"DISABLED_VIDEO": "Video disabled", "DISABLED_VIDEO": "Video disabled",
"CAPTIONS_LANG_TEXT": "Select the language that the participants of the room will use. The captions will appear in that language." "CAPTIONS_LANG_TEXT": "Select the language that the participants of the room will use. The captions will appear in that language.",
"THEME": "Theme"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Background effects", "TITLE": "Background effects",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Subtítulos", "CAPTIONS": "Subtítulos",
"DISABLED_AUDIO": "Audio desactivado", "DISABLED_AUDIO": "Audio desactivado",
"DISABLED_VIDEO": "Video desactivado", "DISABLED_VIDEO": "Video desactivado",
"CAPTIONS_LANG_TEXT": "Selecciona el idioma que usarán los participantes de la sala. Los subtítulos aparecerán en ese idioma." "CAPTIONS_LANG_TEXT": "Selecciona el idioma que usarán los participantes de la sala. Los subtítulos aparecerán en ese idioma.",
"THEME": "Tema"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Efectos de fondo", "TITLE": "Efectos de fondo",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Les sous-titres", "CAPTIONS": "Les sous-titres",
"DISABLED_AUDIO": "Désactiver l'audio", "DISABLED_AUDIO": "Désactiver l'audio",
"DISABLED_VIDEO": "Désactiver la vidéo", "DISABLED_VIDEO": "Désactiver la vidéo",
"CAPTIONS_LANG_TEXT": "Sélectionnez la langue que les participants de la salle utiliseront. Les sous-titres apparaîtront dans cette langue." "CAPTIONS_LANG_TEXT": "Sélectionnez la langue que les participants de la salle utiliseront. Les sous-titres apparaîtront dans cette langue.",
"THEME": "Thème"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Effets de fond", "TITLE": "Effets de fond",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "उपशीर्षक", "CAPTIONS": "उपशीर्षक",
"DISABLED_AUDIO": "ऑडियो अक्षम", "DISABLED_AUDIO": "ऑडियो अक्षम",
"DISABLED_VIDEO": "वीडियो अक्षम", "DISABLED_VIDEO": "वीडियो अक्षम",
"CAPTIONS_LANG_TEXT": "उस भाषा का चयन करें जिसका उपयोग कमरा के प्रतिभागी करेंगे। उपशीर्षक उस भाषा में दिखाई देंगे।" "CAPTIONS_LANG_TEXT": "उस भाषा का चयन करें जिसका उपयोग कमरा के प्रतिभागी करेंगे। उपशीर्षक उस भाषा में दिखाई देंगे।",
"THEME": "थीम"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "पृष्ठभूमि प्रभाव", "TITLE": "पृष्ठभूमि प्रभाव",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Sottotitoli", "CAPTIONS": "Sottotitoli",
"DISABLED_AUDIO": "Disattiva l'audio", "DISABLED_AUDIO": "Disattiva l'audio",
"DISABLED_VIDEO": "Disattiva il video", "DISABLED_VIDEO": "Disattiva il video",
"CAPTIONS_LANG_TEXT": "Seleziona la lingua che i partecipanti della stanza useranno. I sottotitoli appariranno in quella lingua." "CAPTIONS_LANG_TEXT": "Seleziona la lingua che i partecipanti della stanza useranno. I sottotitoli appariranno in quella lingua.",
"THEME": "Tema"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Effetti di sfondo", "TITLE": "Effetti di sfondo",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "字幕", "CAPTIONS": "字幕",
"DISABLED_AUDIO": "オーディオを無効にする", "DISABLED_AUDIO": "オーディオを無効にする",
"DISABLED_VIDEO": "ビデオを無効にする", "DISABLED_VIDEO": "ビデオを無効にする",
"CAPTIONS_LANG_TEXT": "ルームの参加者が使用する言語を選択します。キャプションはその言語で表示されます。" "CAPTIONS_LANG_TEXT": "ルームの参加者が使用する言語を選択します。キャプションはその言語で表示されます。",
"THEME": "テーマ"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "背景効果", "TITLE": "背景効果",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Ondertitels", "CAPTIONS": "Ondertitels",
"DISABLED_AUDIO": "Geen audio", "DISABLED_AUDIO": "Geen audio",
"DISABLED_VIDEO": "Geen video", "DISABLED_VIDEO": "Geen video",
"CAPTIONS_LANG_TEXT": "Selecteer de taal die de deelnemers van de kamer zullen gebruiken. De ondertiteling zal in die taal verschijnen." "CAPTIONS_LANG_TEXT": "Selecteer de taal die de deelnemers van de kamer zullen gebruiken. De ondertiteling zal in die taal verschijnen.",
"THEME": "Thema"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Achtergrondeffecten", "TITLE": "Achtergrondeffecten",

View File

@ -106,7 +106,8 @@
"CAPTIONS": "Legendas", "CAPTIONS": "Legendas",
"DISABLED_AUDIO": "Áudio desativado", "DISABLED_AUDIO": "Áudio desativado",
"DISABLED_VIDEO": "Vídeo desativado", "DISABLED_VIDEO": "Vídeo desativado",
"CAPTIONS_LANG_TEXT": "Selecione o idioma que os participantes da sala utilizarão. Os legendas aparecerão nesse idioma." "CAPTIONS_LANG_TEXT": "Selecione o idioma que os participantes da sala utilizarão. Os legendas aparecerão nesse idioma.",
"THEME": "Tema"
}, },
"BACKGROUND": { "BACKGROUND": {
"TITLE": "Efeitos de fundo", "TITLE": "Efeitos de fundo",

View File

@ -10,6 +10,7 @@ export enum StorageKeys {
LANG = 'lang', LANG = 'lang',
CAPTION_LANG = 'captionLang', CAPTION_LANG = 'captionLang',
BACKGROUND = 'virtualBg', BACKGROUND = 'virtualBg',
THEME = 'theme',
TAB_ID = 'tabId', TAB_ID = 'tabId',
ACTIVE_TABS = 'activeTabs' ACTIVE_TABS = 'activeTabs'
} }

View File

@ -5,8 +5,7 @@
export enum OpenViduThemeMode { export enum OpenViduThemeMode {
Light = 'light', Light = 'light',
Dark = 'dark', Dark = 'dark',
None = 'none', CLASSIC = 'classic'
Auto = 'auto'
} }
/** /**

View File

@ -46,6 +46,7 @@ import { VideoDevicesComponent } from './components/settings/video-devices/video
import { ApiDirectiveModule } from './directives/api/api.directive.module'; import { ApiDirectiveModule } from './directives/api/api.directive.module';
import { OpenViduComponentsDirectiveModule } from './directives/template/openvidu-components-angular.directive.module'; import { OpenViduComponentsDirectiveModule } from './directives/template/openvidu-components-angular.directive.module';
import { AppMaterialModule } from './openvidu-components-angular.material.module'; import { AppMaterialModule } from './openvidu-components-angular.material.module';
import { ThemeSelectorComponent } from './components/settings/theme-selector/theme-selector.component';
const publicComponents = [ const publicComponents = [
AdminDashboardComponent, AdminDashboardComponent,
@ -79,7 +80,8 @@ const privateComponents = [
ParticipantNameInputComponent, ParticipantNameInputComponent,
LangSelectorComponent, LangSelectorComponent,
ToolbarMediaButtonsComponent, ToolbarMediaButtonsComponent,
ToolbarPanelButtonsComponent ToolbarPanelButtonsComponent,
ThemeSelectorComponent
]; ];
@NgModule({ @NgModule({

View File

@ -10,6 +10,7 @@ import {
} from '../../models/storage.model'; } from '../../models/storage.model';
import { LoggerService } from '../logger/logger.service'; import { LoggerService } from '../logger/logger.service';
import { CustomDevice } from '../../models/device.model'; import { CustomDevice } from '../../models/device.model';
import { OpenViduThemeMode } from '../../models/theme.model';
/** /**
* @internal * @internal
@ -230,7 +231,7 @@ export class StorageService implements OnDestroy {
if (!this.isStorageAvailable) return; if (!this.isStorageAvailable) return;
// Use batch removal for better performance // Use batch removal for better performance
const keysToRemove = TAB_SPECIFIC_KEYS.map(key => `${this.PREFIX_KEY}${tabId}_${key}`); const keysToRemove = TAB_SPECIFIC_KEYS.map((key) => `${this.PREFIX_KEY}${tabId}_${key}`);
for (const storageKey of keysToRemove) { for (const storageKey of keysToRemove) {
try { try {
@ -339,6 +340,18 @@ export class StorageService implements OnDestroy {
this.remove(StorageKeys.BACKGROUND); this.remove(StorageKeys.BACKGROUND);
} }
setTheme(theme: OpenViduThemeMode): void {
this.set(StorageKeys.THEME, theme);
}
getTheme(): OpenViduThemeMode | null {
return this.get(StorageKeys.THEME);
}
removeTheme(): void {
this.remove(StorageKeys.THEME);
}
// Core storage methods with improved error handling and caching // Core storage methods with improved error handling and caching
protected set(key: string, item: any): void { protected set(key: string, item: any): void {
if (!this.isStorageAvailable) { if (!this.isStorageAvailable) {
@ -403,9 +416,7 @@ export class StorageService implements OnDestroy {
private setLocalValue(key: string, item: any, useCache: boolean = true): void { private setLocalValue(key: string, item: any, useCache: boolean = true): void {
if (!this.isStorageAvailable) return; if (!this.isStorageAvailable) return;
const storageKey = this.shouldUseTabSpecificKey(key) const storageKey = this.shouldUseTabSpecificKey(key) ? `${this.PREFIX_KEY}${this.tabId}_${key}` : `${this.PREFIX_KEY}${key}`;
? `${this.PREFIX_KEY}${this.tabId}_${key}`
: `${this.PREFIX_KEY}${key}`;
try { try {
// Optimize serialization for primitive types // Optimize serialization for primitive types
@ -436,9 +447,7 @@ export class StorageService implements OnDestroy {
private getLocalValue(key: string, useCache: boolean = true): any { private getLocalValue(key: string, useCache: boolean = true): any {
if (!this.isStorageAvailable) return null; if (!this.isStorageAvailable) return null;
const storageKey = this.shouldUseTabSpecificKey(key) const storageKey = this.shouldUseTabSpecificKey(key) ? `${this.PREFIX_KEY}${this.tabId}_${key}` : `${this.PREFIX_KEY}${key}`;
? `${this.PREFIX_KEY}${this.tabId}_${key}`
: `${this.PREFIX_KEY}${key}`;
// Check cache first // Check cache first
if (useCache && this.cache.has(storageKey)) { if (useCache && this.cache.has(storageKey)) {
@ -484,9 +493,7 @@ export class StorageService implements OnDestroy {
private removeLocalValue(key: string): void { private removeLocalValue(key: string): void {
if (!this.isStorageAvailable) return; if (!this.isStorageAvailable) return;
const storageKey = this.shouldUseTabSpecificKey(key) const storageKey = this.shouldUseTabSpecificKey(key) ? `${this.PREFIX_KEY}${this.tabId}_${key}` : `${this.PREFIX_KEY}${key}`;
? `${this.PREFIX_KEY}${this.tabId}_${key}`
: `${this.PREFIX_KEY}${key}`;
try { try {
this.localStorage.removeItem(storageKey); this.localStorage.removeItem(storageKey);

View File

@ -2,46 +2,25 @@ import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { OPENVIDU_DARK_THEME, OPENVIDU_LIGHT_THEME, OpenViduThemeMode, OpenViduThemeVariables } from '../../models/theme.model'; import { OPENVIDU_DARK_THEME, OPENVIDU_LIGHT_THEME, OpenViduThemeMode, OpenViduThemeVariables } from '../../models/theme.model';
import { StorageService } from '../storage/storage.service';
/** /**
* Service for managing OpenVidu component themes dynamically * Service for managing OpenVidu component themes dynamically
* *
* This service allows you to: * This service allows you to:
* - Switch between light, dark, and auto themes * - Switch between light, dark and classic themes
* - Apply custom theme variables * - Apply custom theme variables
* - Listen to theme changes * - Listen to theme changes
* - Integrate with Angular Material themes * - Integrate with Angular Material themes
* *
* @example
* ```typescript
* // Inject the service
* constructor(private themeService: OpenViduThemeService) {}
*
* // Switch to dark theme
* this.themeService.setTheme('dark');
*
* // Apply custom variables
* this.themeService.updateThemeVariables({
* '--ov-primary-action-color': '#ff5722',
* '--ov-accent-action-color': '#4caf50'
* });
*
* // Listen to theme changes
* this.themeService.currentTheme$.subscribe(theme => {
* console.log('Current theme:', theme);
* });
* ```
*
* @internal * @internal
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class OpenViduThemeService { export class OpenViduThemeService {
private readonly THEME_STORAGE_KEY = 'openvidu-theme';
private readonly THEME_ATTRIBUTE = 'data-ov-theme'; private readonly THEME_ATTRIBUTE = 'data-ov-theme';
private currentThemeSubject = new BehaviorSubject<OpenViduThemeMode>(OpenViduThemeMode.CLASSIC);
private currentThemeSubject = new BehaviorSubject<OpenViduThemeMode>(OpenViduThemeMode.None);
private currentVariablesSubject = new BehaviorSubject<OpenViduThemeVariables>({}); private currentVariablesSubject = new BehaviorSubject<OpenViduThemeVariables>({});
/** /**
@ -54,9 +33,11 @@ export class OpenViduThemeService {
*/ */
public readonly currentVariables$: Observable<OpenViduThemeVariables> = this.currentVariablesSubject.asObservable(); public readonly currentVariables$: Observable<OpenViduThemeVariables> = this.currentVariablesSubject.asObservable();
constructor(@Inject(DOCUMENT) private document: Document) { constructor(
@Inject(DOCUMENT) private document: Document,
protected storageService: StorageService
) {
this.initializeTheme(); this.initializeTheme();
this.setupSystemThemeListener();
} }
/** /**
@ -78,9 +59,9 @@ export class OpenViduThemeService {
* @param theme The theme mode to apply * @param theme The theme mode to apply
*/ */
setTheme(theme: OpenViduThemeMode): void { setTheme(theme: OpenViduThemeMode): void {
this.currentThemeSubject.next(theme);
this.applyTheme(theme); this.applyTheme(theme);
this.saveThemeToStorage(theme); this.currentThemeSubject.next(theme);
this.storageService.setTheme(theme);
} }
/** /**
@ -152,17 +133,19 @@ export class OpenViduThemeService {
} }
private initializeTheme(): void { private initializeTheme(): void {
const savedTheme = this.getThemeFromStorage(); const savedTheme = this.storageService.getTheme();
const initialTheme = savedTheme || OpenViduThemeMode.None; const initialTheme = savedTheme || OpenViduThemeMode.CLASSIC;
this.applyTheme(initialTheme); this.applyTheme(initialTheme);
this.currentThemeSubject.next(initialTheme); this.currentThemeSubject.next(initialTheme);
} }
private applyTheme(theme: OpenViduThemeMode): void { private applyTheme(theme: OpenViduThemeMode): void {
const documentElement = this.document.documentElement; const documentElement = this.document.documentElement;
const currentTheme = this.getCurrentTheme();
if (theme === OpenViduThemeMode.Auto || theme === OpenViduThemeMode.None) { if (theme === OpenViduThemeMode.CLASSIC) {
documentElement.removeAttribute(this.THEME_ATTRIBUTE); documentElement.removeAttribute(this.THEME_ATTRIBUTE);
const currentVariables = this.getDefaultVariablesForTheme(currentTheme);
this.removeCSSVariables(currentVariables);
} else { } else {
documentElement.setAttribute(this.THEME_ATTRIBUTE, theme); documentElement.setAttribute(this.THEME_ATTRIBUTE, theme);
} }
@ -182,57 +165,22 @@ export class OpenViduThemeService {
}); });
} }
private removeCSSVariables(variables: OpenViduThemeVariables): void {
const documentElement = this.document.documentElement;
Object.keys(variables).forEach((property) => {
documentElement.style.removeProperty(property);
});
}
private getDefaultVariablesForTheme(theme: OpenViduThemeMode): OpenViduThemeVariables { private getDefaultVariablesForTheme(theme: OpenViduThemeMode): OpenViduThemeVariables {
switch (theme) { switch (theme) {
case OpenViduThemeMode.Light: case OpenViduThemeMode.Light:
return OPENVIDU_LIGHT_THEME; return OPENVIDU_LIGHT_THEME;
case OpenViduThemeMode.Dark: case OpenViduThemeMode.Dark:
return OPENVIDU_DARK_THEME; return OPENVIDU_DARK_THEME;
case OpenViduThemeMode.None:
return {};
case OpenViduThemeMode.Auto:
// Auto theme - use system preference
return this.prefersDarkMode() ? OPENVIDU_DARK_THEME : OPENVIDU_LIGHT_THEME;
default: default:
return {}; return {};
} }
} }
private setupSystemThemeListener(): void {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
if (this.getCurrentTheme() === OpenViduThemeMode.Auto) {
const defaultVariables = this.getDefaultVariablesForTheme(OpenViduThemeMode.Auto);
this.applyCSSVariables(defaultVariables);
}
};
// Use the newer addEventListener if available, otherwise use the deprecated addListener
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemThemeChange);
}
}
}
private saveThemeToStorage(theme: OpenViduThemeMode): void {
try {
localStorage.setItem(this.THEME_STORAGE_KEY, theme);
} catch (error) {
console.warn('Failed to save theme to localStorage:', error);
}
}
private getThemeFromStorage(): OpenViduThemeMode | null {
try {
const saved = localStorage.getItem(this.THEME_STORAGE_KEY) as OpenViduThemeMode;
if (saved && [OpenViduThemeMode.Light, OpenViduThemeMode.Dark, OpenViduThemeMode.Auto].includes(saved)) {
return saved;
}
} catch (error) {
console.warn('Failed to read theme from localStorage:', error);
}
return null;
}
} }