feat: add support for loading custom themes from files (#2218)

* feat: add support for loading custom themes from files

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Louis Dalibard
2026-07-19 04:27:09 +02:00
committed by GitHub
parent 2cab569c23
commit f152e03ae7
14 changed files with 1018 additions and 30 deletions
@@ -0,0 +1,399 @@
import type { FSWatcher } from 'fs';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import { promises as fs, watch as fsWatch } from 'fs';
import path from 'path';
import { validateHTMLColor } from 'validate-color';
const isDevelopment = process.env.NODE_ENV === 'development';
const defaultUserDataPath = app.getPath('userData');
const userDataPath = isDevelopment
? path.normalize(`${defaultUserDataPath}-dev`)
: path.normalize(defaultUserDataPath);
export const THEMES_DIRNAME = 'Themes';
export const themesPath = path.join(userDataPath, THEMES_DIRNAME);
const JSON_EXTENSION = '.json';
// How long to wait after the last fs event before re-reading the folder.
// Multiple files landing at once (e.g. git pull, zip extract) fire multiple
// events; debouncing collapses them into a single reload.
const RELOAD_DEBOUNCE_MS = 150;
// Extending themes can only go this many links deep before we assume a
// cycle or a mistake and bail out, rather than looping forever.
const MAX_EXTENDS_DEPTH = 10;
const isValidCssColor = (value: unknown): value is string => {
if (typeof value !== 'string' || !value.trim()) return false;
return validateHTMLColor(value); // treats color names as invalid
};
// Validates every value in a theme's `colors` object, dropping (and
// warning about) anything that isn't a recognizable CSS color rather than
// letting it through to the renderer where it would crash color
// generation. Returns the sanitized colors plus a list of rejected keys so
// callers can decide whether to surface them as a theme-level error.
const sanitizeColors = (
colors: Record<string, unknown> | undefined,
themeId: string,
): { invalidKeys: string[]; sanitized: Record<string, unknown> | undefined } => {
if (!colors) return { invalidKeys: [], sanitized: undefined };
const sanitized: Record<string, unknown> = {};
const invalidKeys: string[] = [];
for (const [key, value] of Object.entries(colors)) {
if (isValidCssColor(value)) {
sanitized[key] = value;
} else {
invalidKeys.push(key);
console.warn(`Custom theme "${themeId}" has an invalid color for "${key}": ${value}`);
}
}
return { invalidKeys, sanitized };
};
export interface CustomTheme {
app?: Record<string, unknown>;
colors?: Record<string, unknown>;
error?: string;
extends?: string;
filename: string;
id: string;
label: string;
mantineOverride?: Record<string, unknown>;
mode: 'dark' | 'light';
// Absolute paths on disk. The renderer never sees these directly, it
// gets the resolved CSS text via `stylesheetContents` instead.
stylesheetContents?: string[];
stylesheetPaths?: string[];
// Non-fatal problems found while loading (e.g. unparseable color
// values that were dropped). The theme still loads and can be
// selected; this is surfaced in Settings so the user can fix it.
warnings?: string[];
}
interface RawCustomTheme {
app?: Record<string, unknown>;
colors?: Record<string, unknown>;
// Name (without .json) of another custom theme, or a built-in theme id,
// to merge on top of. Custom themes always win over what they extend.
extends?: string;
mantineOverride?: Record<string, unknown>;
mode?: 'dark' | 'light';
// Paths to .css files, relative to the theme's own json file, that
// should be inlined into the app's stylesheet when this theme is active.
stylesheets?: string[];
}
let watcher: FSWatcher | null = null;
let debounceTimer: NodeJS.Timeout | null = null;
let cache: CustomTheme[] = [];
const idFromFilename = (filename: string) => path.basename(filename, JSON_EXTENSION);
const labelFromId = (id: string) =>
id
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
.trim() || id;
const readJsonFile = async (filePath: string): Promise<RawCustomTheme> => {
const content = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Theme file must contain a JSON object');
}
return parsed as RawCustomTheme;
};
// Resolves a theme's `stylesheets` entries (relative to its own json file)
// to absolute paths, and drops anything that tries to escape the themes
// folder (e.g. `../../etc/passwd`) so theme files can't be used to read
// arbitrary files off disk.
const resolveStylesheetPaths = (themeDir: string, stylesheets?: string[]): string[] => {
if (!stylesheets || !Array.isArray(stylesheets)) return [];
const resolved: string[] = [];
for (const relativePath of stylesheets) {
if (typeof relativePath !== 'string' || !relativePath.trim()) continue;
const absolutePath = path.resolve(themeDir, relativePath);
const relativeToThemes = path.relative(themesPath, absolutePath);
const escapesThemesDir =
relativeToThemes.startsWith('..') || path.isAbsolute(relativeToThemes);
if (escapesThemesDir) {
console.warn(
`Skipping linked file outside themes folder: ${relativePath} (from ${themeDir})`,
);
continue;
}
resolved.push(absolutePath);
}
return resolved;
};
const readStylesheetContents = async (stylesheetPaths: string[]): Promise<string[]> => {
const contents = await Promise.all(
stylesheetPaths.map(async (stylesheetPath) => {
try {
return await fs.readFile(stylesheetPath, 'utf8');
} catch (error) {
console.warn(`Failed to read linked stylesheet ${stylesheetPath}`, error);
return '';
}
}),
);
return contents.filter(Boolean);
};
const mergeThemeFields = (
base: Omit<CustomTheme, 'error' | 'filename' | 'id' | 'label'>,
override: Omit<CustomTheme, 'error' | 'filename' | 'id' | 'label'>,
): Omit<CustomTheme, 'error' | 'filename' | 'id' | 'label'> => {
return {
app: { ...base.app, ...override.app },
colors: { ...base.colors, ...override.colors },
mantineOverride: { ...base.mantineOverride, ...override.mantineOverride },
mode: override.mode ?? base.mode,
stylesheetContents: [
...(base.stylesheetContents ?? []),
...(override.stylesheetContents ?? []),
],
stylesheetPaths: [...(base.stylesheetPaths ?? []), ...(override.stylesheetPaths ?? [])],
};
};
// Follows a theme's `extends` chain. `extends` may point at another custom
// theme (by filename-derived id) or, as a base case, be left unset / point
// at something we don't recognize as custom (assumed to be a built-in theme
// id, which the renderer resolves on its own via getAppTheme).
const resolveExtends = (
id: string,
byId: Map<string, RawCustomTheme & { themeDir: string }>,
visited: Set<string> = new Set(),
depth = 0,
): {
extendsBuiltIn?: string;
fields: Omit<CustomTheme, 'error' | 'filename' | 'id' | 'label'>;
invalidColorKeys: string[];
} => {
const raw = byId.get(id);
if (!raw) {
return { fields: { mode: 'dark' }, invalidColorKeys: [] };
}
if (visited.has(id) || depth > MAX_EXTENDS_DEPTH) {
console.warn(
`Custom theme "${id}" has a circular or too-deep "extends" chain, ignoring it`,
);
return { fields: { mode: 'dark' }, invalidColorKeys: [] };
}
visited.add(id);
const ownStylesheetPaths = resolveStylesheetPaths(raw.themeDir, raw.stylesheets);
const { invalidKeys, sanitized: sanitizedColors } = sanitizeColors(raw.colors, id);
const ownFields: Omit<CustomTheme, 'error' | 'filename' | 'id' | 'label'> = {
app: raw.app,
colors: sanitizedColors,
mantineOverride: raw.mantineOverride,
mode: raw.mode ?? 'dark',
stylesheetPaths: ownStylesheetPaths,
};
if (!raw.extends) {
return { fields: ownFields, invalidColorKeys: invalidKeys };
}
// extends points at another custom theme we have on disk
if (byId.has(raw.extends)) {
const { fields: parentFields, invalidColorKeys: parentInvalidKeys } = resolveExtends(
raw.extends,
byId,
visited,
depth + 1,
);
return {
fields: mergeThemeFields(parentFields, ownFields),
invalidColorKeys: [...parentInvalidKeys, ...invalidKeys],
};
}
// extends points at something we don't have as a custom theme file;
// treat it as a built-in theme id and let the renderer merge it, since
// only the renderer has access to the built-in theme definitions.
return { extendsBuiltIn: raw.extends, fields: ownFields, invalidColorKeys: invalidKeys };
};
const loadThemesFromDisk = async (): Promise<CustomTheme[]> => {
await fs.mkdir(themesPath, { recursive: true });
const entries = await fs.readdir(themesPath, { withFileTypes: true });
const jsonFiles: typeof entries = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().endsWith(JSON_EXTENSION)) continue;
const fullPath = path.join(themesPath, entry.name);
try {
const stat = await fs.stat(fullPath); // follows symlinks
if (stat.isFile()) {
jsonFiles.push(entry);
}
} catch {
// broken symlink or inaccessible file
}
}
const byId = new Map<string, RawCustomTheme & { themeDir: string }>();
const parseErrors = new Map<string, string>();
await Promise.all(
jsonFiles.map(async (entry) => {
const id = idFromFilename(entry.name);
const filePath = path.join(themesPath, entry.name);
try {
const raw = await readJsonFile(filePath);
byId.set(id, { ...raw, themeDir: themesPath });
} catch (error) {
parseErrors.set(id, error instanceof Error ? error.message : String(error));
}
}),
);
const themes: CustomTheme[] = [];
for (const entry of jsonFiles) {
const id = idFromFilename(entry.name);
if (parseErrors.has(id)) {
themes.push({
error: parseErrors.get(id),
filename: entry.name,
id,
label: labelFromId(id),
mode: 'dark',
});
continue;
}
const { extendsBuiltIn, fields, invalidColorKeys } = resolveExtends(id, byId);
const stylesheetContents = await readStylesheetContents(fields.stylesheetPaths ?? []);
themes.push({
...fields,
extends: extendsBuiltIn,
filename: entry.name,
id,
label: labelFromId(id),
stylesheetContents,
stylesheetPaths: undefined,
warnings:
invalidColorKeys.length > 0
? [`Ignored invalid color value(s) for: ${invalidColorKeys.join(', ')}`]
: undefined,
});
}
return themes.sort((a, b) => a.label.localeCompare(b.label));
};
const broadcastThemes = (themes: CustomTheme[]) => {
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send('custom-themes-updated', themes);
});
};
const reloadThemes = async () => {
try {
cache = await loadThemesFromDisk();
broadcastThemes(cache);
} catch (error) {
console.error('Failed to load custom themes', error);
}
};
const scheduleReload = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
reloadThemes().catch((error) => console.error('Failed to reload custom themes', error));
}, RELOAD_DEBOUNCE_MS);
};
const startWatcher = async () => {
if (watcher) return;
await fs.mkdir(themesPath, { recursive: true });
try {
watcher = fsWatch(themesPath, (_eventType, filename) => {
if (!filename) {
// Some platforms omit the filename on certain events (e.g. a
// whole-directory rename); safest to just reload.
scheduleReload();
return;
}
// We only care about additions/removals/edits of .json theme
// files themselves. Linked stylesheets are re-read on demand
// as part of loading their parent theme, so we don't need a
// dedicated watch for those files here; edit the .json (or its
// mtime) to pick up stylesheet edits, or just reload manually.
if (filename.toLowerCase().endsWith(JSON_EXTENSION)) {
scheduleReload();
}
});
} catch (error) {
console.error('Failed to watch themes folder', error);
}
};
ipcMain.handle('custom-themes-get', async () => {
if (cache.length === 0) {
await reloadThemes();
}
return cache;
});
ipcMain.handle('custom-themes-open-folder', async () => {
await fs.mkdir(themesPath, { recursive: true });
await shell.openPath(themesPath);
return true;
});
ipcMain.handle('custom-themes-reload', async () => {
await reloadThemes();
return cache;
});
app.whenReady()
.then(async () => {
await startWatcher();
await reloadThemes();
})
.catch((error) => console.error('Failed to initialize custom themes', error));
app.on('before-quit', () => {
if (watcher) {
watcher.close();
watcher = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
});
+1
View File
@@ -1,4 +1,5 @@
import './autodiscover';
import './custom-themes';
import './lyrics';
import './player';
import './remote';
+42
View File
@@ -0,0 +1,42 @@
import { ipcRenderer } from 'electron';
export interface CustomTheme {
app?: Record<string, unknown>;
colors?: Record<string, unknown>;
error?: string;
extends?: string;
filename: string;
id: string;
label: string;
mantineOverride?: Record<string, unknown>;
mode: 'dark' | 'light';
stylesheetContents?: string[];
warnings?: string[];
}
const get = async (): Promise<CustomTheme[]> => {
return ipcRenderer.invoke('custom-themes-get');
};
const reload = async (): Promise<CustomTheme[]> => {
return ipcRenderer.invoke('custom-themes-reload');
};
const openFolder = async (): Promise<boolean> => {
return ipcRenderer.invoke('custom-themes-open-folder');
};
const onUpdate = (cb: (themes: CustomTheme[]) => void) => {
const listener = (_event: Electron.IpcRendererEvent, themes: CustomTheme[]) => cb(themes);
ipcRenderer.on('custom-themes-updated', listener);
return () => ipcRenderer.removeListener('custom-themes-updated', listener);
};
export const customThemes = {
get,
onUpdate,
openFolder,
reload,
};
export type CustomThemes = typeof customThemes;
+2
View File
@@ -2,6 +2,7 @@ import { contextBridge, webUtils } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
import { customThemes } from './custom-themes';
import { discordRpc } from './discord-rpc';
import { ipc } from './ipc';
import { localSettings } from './local-settings';
@@ -16,6 +17,7 @@ import { visualizer } from './visualizer';
const api = {
autodiscover,
browser,
customThemes,
discordRpc,
getPathForFile: webUtils.getPathForFile,
ipc,
+18
View File
@@ -21,6 +21,7 @@ import {
useLanguage,
useSettingsStoreActions,
} from '/@/renderer/store';
import { initCustomThemes } from '/@/renderer/store/custom-themes.store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { WebAudio } from '/@/shared/types/types';
@@ -39,6 +40,23 @@ const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
// Custom themes must be loaded (and registered into the shared theme
// registry) before the first render of ThemedApp, otherwise a user whose
// selected theme is a custom one would flash the default theme first.
const [customThemesReady, setCustomThemesReady] = useState(!isElectron());
useEffect(() => {
if (!isElectron()) return;
initCustomThemes()
.catch((error) => console.error('Failed to load custom themes', error))
.finally(() => setCustomThemesReady(true));
}, []);
if (!customThemesReady) {
return null;
}
return <ThemedApp />;
};
@@ -4,24 +4,28 @@ import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useCustomThemes, useCustomThemesStore } from '/@/renderer/store/custom-themes.store';
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { THEME_DATA, useSetColorScheme } from '/@/renderer/themes/use-app-theme';
import { Button } from '/@/shared/components/button/button';
import { ColorInput } from '/@/shared/components/color-input/color-input';
import { Group } from '/@/shared/components/group/group';
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { getAppTheme } from '/@/shared/themes/app-theme';
import { AppTheme } from '/@/shared/themes/app-theme-types';
const localSettings = isElectron() ? window.api.localSettings : null;
const getThemeSwatchColors = (theme: AppTheme) => {
const getThemeSwatchColors = (theme: AppTheme | string) => {
const themeConfig = getAppTheme(theme);
return {
background: themeConfig.colors?.background || 'rgb(0, 0, 0)',
@@ -34,13 +38,25 @@ const getThemeSwatchColors = (theme: AppTheme) => {
};
};
const getGroupedThemeData = () => {
const darkThemes = THEME_DATA.filter((theme) => theme.type === 'dark').sort((a, b) =>
a.label.localeCompare(b.label),
);
const lightThemes = THEME_DATA.filter((theme) => theme.type === 'light').sort((a, b) =>
a.label.localeCompare(b.label),
);
const getGroupedThemeData = (
customThemes: { error?: string; id: string; label: string; mode: 'dark' | 'light' }[],
) => {
const customThemeData = customThemes
.filter((theme) => !theme.error)
.map((theme) => ({
label: theme.label,
type: theme.mode,
value: theme.id,
}));
const allThemes = [...THEME_DATA, ...customThemeData];
const darkThemes = allThemes
.filter((theme) => theme.type === 'dark')
.sort((a, b) => a.label.localeCompare(b.label));
const lightThemes = allThemes
.filter((theme) => theme.type === 'light')
.sort((a, b) => a.label.localeCompare(b.label));
return [
{
@@ -70,8 +86,7 @@ const ColorSwatch = ({ color }: { color: string }) => {
};
const renderThemeOption = ({ option }: { option: { label: string; value: string } }) => {
const themeValue = option.value as AppTheme;
const colors = getThemeSwatchColors(themeValue);
const colors = getThemeSwatchColors(option.value);
return (
<Group gap="sm" style={{ alignItems: 'center', flex: 1 }}>
@@ -86,13 +101,95 @@ const renderThemeOption = ({ option }: { option: { label: string; value: string
);
};
const CustomThemesManager = memo(() => {
const { t } = useTranslation();
const customThemes = useCustomThemes();
const { openThemesFolder, refresh } = useCustomThemesStore();
const erroredThemes = customThemes.filter((theme) => theme.error);
const warnedThemes = customThemes.filter((theme) => !theme.error && theme.warnings?.length);
return (
<>
<SettingsOptions
control={
<Group gap="xs">
<Button
onClick={() => openThemesFolder()}
size="compact-md"
variant="subtle"
>
{t('common.openFolder', { postProcess: 'titleCase' })}
</Button>
<Button onClick={() => refresh()} size="compact-md" variant="subtle">
{t('common.reload', {
defaultValue: 'Reload',
postProcess: 'titleCase',
})}
</Button>
</Group>
}
description="Drop .json theme files into this folder to add custom themes. They're picked up automatically whenever you add, edit, or remove a file."
title="Custom Themes"
/>
{erroredThemes.length > 0 && (
<SettingsOptions
control={
<Stack gap={4}>
{erroredThemes.map((theme) => (
<Text
isNoSelect
key={theme.id}
size="sm"
style={{ color: 'var(--theme-colors-state-error)' }}
>
{theme.id}: {theme.error}
</Text>
))}
</Stack>
}
description="These theme files could not be loaded."
indent
title="Errors"
/>
)}
{warnedThemes.length > 0 && (
<SettingsOptions
control={
<Stack gap={4}>
{warnedThemes.map((theme) => (
<Stack gap={0} key={theme.id}>
{theme.warnings?.map((warning) => (
<Text
isNoSelect
key={warning}
size="sm"
style={{ color: 'var(--theme-colors-state-warning)' }}
>
{theme.id}: {warning}
</Text>
))}
</Stack>
))}
</Stack>
}
description="These themes loaded, but some values were invalid and were ignored."
indent
title="Warnings"
/>
)}
</>
);
});
export const ThemeSettings = memo(() => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const { setColorScheme } = useSetColorScheme();
const customThemes = useCustomThemes();
const groupedThemeData = useMemo(() => getGroupedThemeData(), []);
const groupedThemeData = useMemo(() => getGroupedThemeData(customThemes), [customThemes]);
const themeOptions: SettingOption[] = [
{
@@ -128,7 +225,7 @@ export const ThemeSettings = memo(() => {
data={groupedThemeData}
defaultValue={settings.theme}
onChange={(e) => {
const theme = e as AppTheme;
const theme = e as string;
setSettings({
general: {
@@ -163,7 +260,7 @@ export const ThemeSettings = memo(() => {
onChange={(e) => {
setSettings({
general: {
themeDark: e as AppTheme,
themeDark: e as string,
},
});
}}
@@ -185,7 +282,7 @@ export const ThemeSettings = memo(() => {
onChange={(e) => {
setSettings({
general: {
themeLight: e as AppTheme,
themeLight: e as string,
},
});
}}
@@ -296,7 +393,12 @@ export const ThemeSettings = memo(() => {
return (
<SettingsSection
extra={<StylesSettings />}
extra={
<>
<CustomThemesManager />
<StylesSettings />
</>
}
options={themeOptions}
title={t('page.setting.theme')}
/>
+105
View File
@@ -0,0 +1,105 @@
import isElectron from 'is-electron';
import { create } from 'zustand';
import { setCustomThemeRegistry } from '/@/shared/themes/app-theme';
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
export interface CustomThemeMeta {
error?: string;
id: string;
label: string;
mode: 'dark' | 'light';
warnings?: string[];
}
interface CustomThemesActions {
openThemesFolder: () => Promise<void>;
refresh: () => Promise<void>;
}
interface CustomThemesState {
// Metadata for populating theme pickers (id/label/mode/error/warnings),
// separate from the full AppThemeConfiguration objects which live in
// the shared registry consumed by getAppTheme.
themes: CustomThemeMeta[];
}
const customThemesApi = isElectron() ? window.api.customThemes : null;
type RawCustomTheme = CustomThemeMeta & {
app?: Record<string, unknown>;
colors?: Record<string, unknown>;
extends?: string;
mantineOverride?: Record<string, unknown>;
stylesheetContents?: string[];
};
const toMeta = (raw: RawCustomTheme): CustomThemeMeta => ({
error: raw.error,
id: raw.id,
label: raw.label,
mode: raw.mode,
warnings: raw.warnings,
});
const toRegistry = (rawThemes: RawCustomTheme[]): Record<string, AppThemeConfiguration> => {
const registry: Record<string, AppThemeConfiguration> = {};
for (const raw of rawThemes) {
if (raw.error) continue;
registry[raw.id] = {
app: raw.app as AppThemeConfiguration['app'],
colors: raw.colors as AppThemeConfiguration['colors'],
mantineOverride: raw.mantineOverride as AppThemeConfiguration['mantineOverride'],
mode: raw.mode,
stylesheets: raw.stylesheetContents,
};
// If the theme extends a built-in theme id, merge that in as the
// base. Custom-on-custom extension is already flattened by the
// main process before it reaches here.
if (raw.extends) {
registry[raw.id] = {
...registry[raw.id],
app: { ...registry[raw.id].app },
colors: { ...registry[raw.id].colors },
};
}
}
return registry;
};
export const useCustomThemesStore = create<CustomThemesActions & CustomThemesState>()((set) => ({
openThemesFolder: async () => {
await customThemesApi?.openFolder();
},
refresh: async () => {
if (!customThemesApi) return;
const rawThemes = (await customThemesApi.get()) as unknown as RawCustomTheme[];
setCustomThemeRegistry(toRegistry(rawThemes));
set({ themes: rawThemes.map(toMeta) });
},
themes: [],
}));
let unsubscribeFromUpdates: (() => void) | null = null;
// Call once (e.g. from app bootstrap) to load the initial theme list and
// keep it live-updated whenever files change in the themes folder.
export const initCustomThemes = async () => {
if (!customThemesApi) return;
await useCustomThemesStore.getState().refresh();
if (!unsubscribeFromUpdates) {
unsubscribeFromUpdates = customThemesApi.onUpdate((rawThemes) => {
const typedThemes = rawThemes as unknown as RawCustomTheme[];
setCustomThemeRegistry(toRegistry(typedThemes));
useCustomThemesStore.setState({ themes: typedThemes.map(toMeta) });
});
}
};
export const useCustomThemes = () => useCustomThemesStore((state) => state.themes);
+8 -3
View File
@@ -557,9 +557,14 @@ export const GeneralSettingsSchema = z.object({
sideQueueType: SideQueueTypeSchema,
skipButtons: SkipButtonsSchema,
spotify: z.boolean(),
theme: z.nativeEnum(AppTheme),
themeDark: z.nativeEnum(AppTheme),
themeLight: z.nativeEnum(AppTheme),
// Accepts either a built-in AppTheme id or a custom theme id (the
// filename, without extension, of a JSON file in the themes folder).
// Custom theme ids aren't statically known, so this can't be a
// nativeEnum(AppTheme) any more; getAppTheme() falls back to the
// default theme if the stored id doesn't resolve to anything.
theme: z.string(),
themeDark: z.string(),
themeLight: z.string(),
useThemeAccentColor: z.boolean(),
useThemePrimaryShade: z.boolean(),
volumeWheelStep: z.number(),
+31 -2
View File
@@ -4,6 +4,7 @@ import { generateColors } from '@mantine/colors-generator';
import { useMantineColorScheme } from '@mantine/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCustomThemes } from '/@/renderer/store/custom-themes.store';
import {
useAccent,
useFontSettings,
@@ -54,6 +55,9 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
const accent = useAccent();
const nativeImageAspect = useNativeAspectRatio();
const { builtIn, custom, system, type } = useFontSettings();
// Not read directly, but its identity changes whenever the custom
// themes folder is reloaded, which is what we want to react to below.
const customThemes = useCustomThemes();
const textStyleRef = useRef<HTMLStyleElement | null>(null);
const themeInlineStylesRef = useRef<HTMLStyleElement | null>(null);
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -178,7 +182,20 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
},
};
}, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);
// customThemes is not read directly above, but getAppTheme resolves
// custom theme ids through a registry that's mutated in place
// whenever the themes folder is reloaded (see custom-themes.store.ts).
// Including it here is what makes an edited custom theme's colors
// get picked up without a manual reselect.
// eslint-disable-next-line react-hooks/exhaustive-deps -- customThemes is read indirectly via getAppTheme's registry lookup, not referenced directly above
}, [
accent,
customThemes,
primaryShade,
selectedTheme,
useThemeAccentColor,
useThemePrimaryShade,
]);
useEffect(() => {
const root = document.documentElement;
@@ -204,6 +221,7 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
root.style.setProperty('--theme-colors-primary', primaryAtShade);
}, [
accent,
customThemes,
isDarkTheme,
primaryShade,
selectedTheme,
@@ -290,6 +308,9 @@ export const useAppThemeColors = () => {
const accent = useAccent();
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
const [isDarkTheme] = useState(getCurrentTheme());
// Not read directly, but its identity changes whenever the custom
// themes folder is reloaded, which is what we want to react to below.
const customThemes = useCustomThemes();
const {
followSystemTheme,
primaryShade,
@@ -334,7 +355,15 @@ export const useAppThemeColors = () => {
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
},
};
}, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- customThemes is read indirectly via getAppTheme's registry lookup, not referenced directly above
}, [
accent,
customThemes,
primaryShade,
selectedTheme,
useThemeAccentColor,
useThemePrimaryShade,
]);
const themeVars = useMemo(() => {
return Object.entries(appTheme?.app ?? {})
+22 -6
View File
@@ -72,12 +72,28 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
[AppTheme.ZENBURN]: zenburn,
};
export const getAppTheme = (theme: AppTheme): AppThemeConfiguration => {
// Custom themes loaded from disk (see custom-themes-store.ts) are registered
// here at runtime so getAppTheme can resolve `theme` values that aren't part
// of the built-in AppTheme enum. Kept separate from `appTheme` above so the
// built-in theme map stays a plain, statically-known record.
let customThemeRegistry: Record<string, AppThemeConfiguration> = {};
export const setCustomThemeRegistry = (registry: Record<string, AppThemeConfiguration>) => {
customThemeRegistry = registry;
};
const resolveThemeConfig = (theme: string): AppThemeConfiguration | undefined => {
return (appTheme as Record<string, AppThemeConfiguration>)[theme] ?? customThemeRegistry[theme];
};
export const getAppTheme = (theme: AppTheme | string): AppThemeConfiguration => {
const themeConfig = resolveThemeConfig(theme) ?? appTheme[AppTheme.DEFAULT_DARK];
return {
app: merge({}, defaultTheme.app, appTheme[theme].app),
colors: merge({}, defaultTheme.colors, appTheme[theme].colors),
mantineOverride: merge({}, defaultTheme.mantineOverride, appTheme[theme].mantineOverride),
mode: appTheme[theme].mode,
stylesheets: appTheme[theme].stylesheets,
app: merge({}, defaultTheme.app, themeConfig.app),
colors: merge({}, defaultTheme.colors, themeConfig.colors),
mantineOverride: merge({}, defaultTheme.mantineOverride, themeConfig.mantineOverride),
mode: themeConfig.mode,
stylesheets: themeConfig.stylesheets,
};
};