From f152e03ae7fb2dfda4a67a64772a388bf603d8a5 Mon Sep 17 00:00:00 2001 From: Louis Dalibard Date: Sun, 19 Jul 2026 04:27:09 +0200 Subject: [PATCH] feat: add support for loading custom themes from files (#2218) * feat: add support for loading custom themes from files --------- Co-authored-by: jeffvli --- README.md | 9 +- docs/CUSTOM_THEMES.md | 259 ++++++++++++ package.json | 1 + pnpm-lock.yaml | 8 + src/main/features/core/custom-themes/index.ts | 399 ++++++++++++++++++ src/main/features/core/index.ts | 1 + src/preload/custom-themes.ts | 42 ++ src/preload/index.ts | 2 + src/renderer/app.tsx | 18 + .../components/general/theme-settings.tsx | 132 +++++- src/renderer/store/custom-themes.store.ts | 105 +++++ src/renderer/store/settings.store.ts | 11 +- src/renderer/themes/use-app-theme.ts | 33 +- src/shared/themes/app-theme.ts | 28 +- 14 files changed, 1018 insertions(+), 30 deletions(-) create mode 100644 docs/CUSTOM_THEMES.md create mode 100644 src/main/features/core/custom-themes/index.ts create mode 100644 src/preload/custom-themes.ts create mode 100644 src/renderer/store/custom-themes.store.ts diff --git a/README.md b/README.md index 946c5f4a9..e50b9407f 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,7 @@ For media keys to work, you will be prompted to allow Feishin to be a Trusted Ac Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin). -Alternatively, you can install it as an Appimage. -We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. -Finally, it generates a `.desktop` file to add Feishin to your Application Launcher. +Alternatively, you can install it as an Appimage. We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher. Simply run the installer like this: @@ -172,7 +170,6 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi - [Plex](https://www.plex.tv/media-server-downloads) - [Feishin fork by lux032](https://github.com/lux032/feishin) - Plex is not natively supported. Use the fork by lux032 to use Plex with Feishin. - ### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid. @@ -184,6 +181,10 @@ sudo chown root:root chrome-sandbox Ubuntu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes. +### How can I add custom themes? + +On the desktop app, you can add custom themes by dropping JSON files into the Themes folder (Settings → General → Theme → Open Folder). See [the custom themes documentation](docs/CUSTOM_THEMES.md) for the file format and examples. + ## Development Built and tested using Node `v23.11.0`. diff --git a/docs/CUSTOM_THEMES.md b/docs/CUSTOM_THEMES.md new file mode 100644 index 000000000..8ad24c3eb --- /dev/null +++ b/docs/CUSTOM_THEMES.md @@ -0,0 +1,259 @@ +# Custom Themes + +Custom themes let you add your own light and dark themes to the **desktop** app without rebuilding Feishin. Drop JSON theme files into the Themes folder; Feishin watches that folder and reloads themes when files change. + +Custom themes are **Electron / desktop only**. They are not available in the web or Docker builds. + +--- + +## Getting started + +1. Open **Settings → General → Theme**. +2. Under **Custom Themes**, click **Open Folder** to reveal the Themes directory. +3. Add a `.json` file (for example `my-theme.json`). +4. Select the theme from the Theme dropdown (it appears under Dark or Light based on `mode`). + +You can also click **Reload** in Settings if a change was not picked up automatically. + +### Themes folder location + +| Platform | Typical path | +|----------|----------------| +| Windows | `%APPDATA%\feishin\Themes` | +| macOS | `~/Library/Application Support/feishin/Themes` | +| Linux | `~/.config/feishin/Themes` | + +In development builds the folder name is under `feishin-dev` instead of `feishin`. + +Theme files must be **`.json` files in the root of the Themes folder** (not nested in subfolders). Linked stylesheets may live next to them or in subfolders, as long as they stay inside Themes. + +--- + +## Theme file format + +Each theme is a single JSON object. + +```json +{ + "mode": "dark", + "extends": "defaultDark", + "colors": { + "primary": "rgb(53, 116, 252)", + "background": "rgb(12, 12, 12)", + "foreground": "rgb(225, 225, 225)" + }, + "app": { + "scrollbar-size": "9px" + }, + "mantineOverride": { + "primaryShade": { + "dark": 6 + } + }, + "stylesheets": ["my-theme.css"] +} +``` + +### Identity + +| Derived from | Behavior | +|--------------|----------| +| **id** | Filename without `.json` (e.g. `rose-pine-custom.json` → `rose-pine-custom`) | +| **label** | Title-cased id with `-` / `_` replaced by spaces (e.g. `Rose Pine Custom`) | + +Use a unique filename. The id is what appears in settings and what you use when another theme `extends` this one. + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `mode` | No (default `dark`) | `"dark"` or `"light"`. Controls which theme group the theme appears in and the app color scheme. | +| `extends` | No | Built-in theme id or another custom theme’s id (filename without `.json`). Your theme is merged on top of the base. | +| `colors` | No | Palette overrides. Invalid CSS colors are dropped and shown as a warning in Settings. | +| `app` | No | App chrome / layout CSS variable overrides. | +| `mantineOverride` | No | Partial [Mantine theme override](https://mantine.dev/theming/theme-object/). | +| `stylesheets` | No | Array of CSS file paths **relative to the Themes folder**. Contents are inlined when the theme is active. Paths that escape the Themes folder are ignored. | + +Unspecified color and app values fall back to Feishin’s default theme (and then to anything provided by `extends`). + +--- + +## Colors + +Supported `colors` keys: + +| Key | Typical use | +|-----|-------------| +| `background` | Main app background | +| `background-alternate` | Alternate / nested background | +| `surface` | Cards, inputs, elevated surfaces | +| `surface-foreground` | Text on surfaces | +| `foreground` | Primary text | +| `foreground-muted` | Secondary / muted text | +| `primary` | Accent / primary action color (also drives generated primary shades) | +| `black` | Black token | +| `white` | White token | +| `state-error` | Error state | +| `state-info` | Info state | +| `state-success` | Success state | +| `state-warning` | Warning state | + +Values must be valid CSS colors recognized by Feishin’s color validator (for example `rgb(...)`, `rgba(...)`, `#rrggbb`, `#rgb`). Named CSS colors like `red` are rejected. Invalid entries are ignored and listed under Custom Themes warnings in Settings. + +--- + +## App variables + +Supported `app` keys (CSS values as strings): + +| Key | Description | +|-----|-------------| +| `content-max-width` | Max width of main content | +| `root-font-size` | Root font size | +| `overlay-header` | Header overlay background | +| `overlay-subheader` | Subheader overlay background | +| `scrollbar-size` | Scrollbar thickness | +| `scrollbar-handle-background` | Scrollbar handle | +| `scrollbar-handle-hover-background` | Handle on hover | +| `scrollbar-handle-active-background` | Handle when active | +| `scrollbar-handle-border-radius` | Handle corner radius | +| `scrollbar-track-background` | Scrollbar track | +| `scrollbar-track-hover-background` | Track on hover | +| `scrollbar-track-active-background` | Track when active | +| `scrollbar-track-border-radius` | Track corner radius | + +--- + +## Extending themes + +Use `extends` to start from a built-in or another custom theme and only override what you need. + +```json +{ + "mode": "dark", + "extends": "nord", + "colors": { + "primary": "#88c0d0" + } +} +``` + +Rules: + +- Custom theme fields always win over the theme they extend. +- Custom → custom chains are flattened when loading (depth limit of 10; cycles are ignored). +- If `extends` is a built-in id, merging with that built-in’s defaults happens in the renderer. +- If `extends` is omitted, the theme still merges onto Feishin’s shared default palette. + +### Built-in theme ids + +Use these as `extends` values (same ids as in Settings): + +`ayuDark`, `ayuLight`, `catppuccinLatte`, `catppuccinMocha`, `defaultDark`, `defaultLight`, `dracula`, `everforestDark`, `everforestLight`, `githubDark`, `githubLight`, `glassyDark`, `gruvboxDark`, `gruvboxLight`, `highContrastDark`, `highContrastLight`, `materialDark`, `materialLight`, `monokai`, `nightOwl`, `nord`, `oneDark`, `rosePine`, `rosePineDawn`, `rosePineMoon`, `shadesOfPurple`, `solarizedDark`, `solarizedLight`, `tokyoNight`, `vscodeDarkPlus`, `vscodeLightPlus`, `zenburn` + +--- + +## Linked stylesheets + +For larger visual overrides (glass effects, layout tweaks, etc.), point `stylesheets` at CSS files inside the Themes folder: + +```json +{ + "mode": "dark", + "extends": "defaultDark", + "stylesheets": ["overrides/my-theme.css"] +} +``` + +```text +Themes/ + my-theme.json + overrides/ + my-theme.css +``` + +Notes: + +- Feishin watches **JSON** theme files for automatic reload. After editing only a CSS file, click **Reload** in Settings (or touch/save the `.json` file) so stylesheets are re-read. +- Empty or unreadable stylesheet paths are skipped with a console warning. + +--- + +## Examples + +### Minimal accent-only dark theme + +`accent-blue.json`: + +```json +{ + "mode": "dark", + "extends": "defaultDark", + "colors": { + "primary": "rgb(80, 160, 255)" + } +} +``` + +### Full light palette + +`paper-light.json`: + +```json +{ + "mode": "light", + "colors": { + "background": "rgb(250, 249, 246)", + "background-alternate": "rgb(242, 240, 235)", + "surface": "rgb(255, 255, 255)", + "surface-foreground": "rgb(40, 40, 40)", + "foreground": "rgb(30, 30, 30)", + "foreground-muted": "rgb(110, 110, 110)", + "primary": "rgb(180, 83, 9)", + "black": "rgb(0, 0, 0)", + "white": "rgb(255, 255, 255)", + "state-error": "rgb(185, 28, 28)", + "state-info": "rgb(37, 99, 235)", + "state-success": "rgb(22, 163, 74)", + "state-warning": "rgb(217, 119, 6)" + }, + "app": { + "overlay-header": "linear-gradient(rgb(250 249 246 / 50%) 0%, rgb(250 249 246 / 80%))", + "scrollbar-handle-background": "rgba(120, 120, 120, 30%)" + }, + "mantineOverride": { + "primaryShade": { + "light": 5 + } + } +} +``` + +### Theme that extends another custom theme + +`nord-soft.json`: + +```json +{ + "mode": "dark", + "extends": "nord", + "colors": { + "background": "rgb(36, 41, 51)", + "surface": "rgb(46, 52, 64)" + } +} +``` + +--- + +## Troubleshooting + +| Symptom | What to check | +|---------|----------------| +| Theme missing from the list | File must be `.json` in the Themes root; use **Reload**. | +| Theme shows an error in Settings | JSON is invalid or not a top-level object. Fix the file and reload. | +| Theme loads with a warning | One or more `colors` values were invalid and ignored. | +| CSS changes not applying | Edit/save the `.json` or click **Reload**; stylesheet watch is tied to JSON changes. | +| Extending does nothing / looks wrong | Confirm the `extends` id matches a built-in id or another custom filename (without `.json`). Avoid circular chains. | + +Broken themes still appear in Settings with their error message so you can fix them without digging through logs. diff --git a/package.json b/package.json index 3435f17ef..eadf4118d 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "semver": "^7.8.2", "string-to-color": "^2.2.2", "taglib-wasm": "^1.5.1", + "validate-color": "^2.2.4", "wavesurfer.js": "^7.12.7", "ws": "^8.21.0", "zod": "^3.25.76", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a539bca2f..c6c10dfb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: taglib-wasm: specifier: ^1.5.1 version: 1.5.1(typescript@5.9.3) + validate-color: + specifier: ^2.2.4 + version: 2.2.4 wavesurfer.js: specifier: ^7.12.7 version: 7.12.8 @@ -5443,6 +5446,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + validate-color@2.2.4: + resolution: {integrity: sha512-Znolz+b6CwW6eBXYld7MFM3O7funcdyRfjKC/X9hqYV/0VcC5LB/L45mff7m3dIn9wdGdNOAQ/fybNuD5P/HDw==} + value-or-function@4.0.0: resolution: {integrity: sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==} engines: {node: '>= 10.13.0'} @@ -11321,6 +11327,8 @@ snapshots: util-deprecate@1.0.2: {} + validate-color@2.2.4: {} + value-or-function@4.0.0: {} vinyl-contents@2.0.0: diff --git a/src/main/features/core/custom-themes/index.ts b/src/main/features/core/custom-themes/index.ts new file mode 100644 index 000000000..8a8a9f479 --- /dev/null +++ b/src/main/features/core/custom-themes/index.ts @@ -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 | undefined, + themeId: string, +): { invalidKeys: string[]; sanitized: Record | undefined } => { + if (!colors) return { invalidKeys: [], sanitized: undefined }; + + const sanitized: Record = {}; + 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; + colors?: Record; + error?: string; + extends?: string; + filename: string; + id: string; + label: string; + mantineOverride?: Record; + 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; + colors?: Record; + // 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; + 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 => { + 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 => { + 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, + override: Omit, +): Omit => { + 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, + visited: Set = new Set(), + depth = 0, +): { + extendsBuiltIn?: string; + fields: Omit; + 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 = { + 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 => { + 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(); + const parseErrors = new Map(); + + 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; + } +}); diff --git a/src/main/features/core/index.ts b/src/main/features/core/index.ts index 5116ae027..464d76464 100644 --- a/src/main/features/core/index.ts +++ b/src/main/features/core/index.ts @@ -1,4 +1,5 @@ import './autodiscover'; +import './custom-themes'; import './lyrics'; import './player'; import './remote'; diff --git a/src/preload/custom-themes.ts b/src/preload/custom-themes.ts new file mode 100644 index 000000000..763e96bde --- /dev/null +++ b/src/preload/custom-themes.ts @@ -0,0 +1,42 @@ +import { ipcRenderer } from 'electron'; + +export interface CustomTheme { + app?: Record; + colors?: Record; + error?: string; + extends?: string; + filename: string; + id: string; + label: string; + mantineOverride?: Record; + mode: 'dark' | 'light'; + stylesheetContents?: string[]; + warnings?: string[]; +} + +const get = async (): Promise => { + return ipcRenderer.invoke('custom-themes-get'); +}; + +const reload = async (): Promise => { + return ipcRenderer.invoke('custom-themes-reload'); +}; + +const openFolder = async (): Promise => { + 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; diff --git a/src/preload/index.ts b/src/preload/index.ts index 9953bc312..f2070a35f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 88f84c60e..ded22e1fc 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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 ; }; diff --git a/src/renderer/features/settings/components/general/theme-settings.tsx b/src/renderer/features/settings/components/general/theme-settings.tsx index fe4c62a05..c85057246 100644 --- a/src/renderer/features/settings/components/general/theme-settings.tsx +++ b/src/renderer/features/settings/components/general/theme-settings.tsx @@ -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 ( @@ -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 ( + <> + + + + + } + 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 && ( + + {erroredThemes.map((theme) => ( + + {theme.id}: {theme.error} + + ))} + + } + description="These theme files could not be loaded." + indent + title="Errors" + /> + )} + {warnedThemes.length > 0 && ( + + {warnedThemes.map((theme) => ( + + {theme.warnings?.map((warning) => ( + + {theme.id}: {warning} + + ))} + + ))} + + } + 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 ( } + extra={ + <> + + + + } options={themeOptions} title={t('page.setting.theme')} /> diff --git a/src/renderer/store/custom-themes.store.ts b/src/renderer/store/custom-themes.store.ts new file mode 100644 index 000000000..f3bce290b --- /dev/null +++ b/src/renderer/store/custom-themes.store.ts @@ -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; + refresh: () => Promise; +} + +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; + colors?: Record; + extends?: string; + mantineOverride?: Record; + 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 => { + const registry: Record = {}; + + 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()((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); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index c52744392..5a7579158 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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(), diff --git a/src/renderer/themes/use-app-theme.ts b/src/renderer/themes/use-app-theme.ts index c9abea2ee..c554307d9 100644 --- a/src/renderer/themes/use-app-theme.ts +++ b/src/renderer/themes/use-app-theme.ts @@ -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(null); const themeInlineStylesRef = useRef(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 ?? {}) diff --git a/src/shared/themes/app-theme.ts b/src/shared/themes/app-theme.ts index ae7aa76f7..7c8d8abfb 100644 --- a/src/shared/themes/app-theme.ts +++ b/src/shared/themes/app-theme.ts @@ -72,12 +72,28 @@ export const appTheme: Record = { [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 = {}; + +export const setCustomThemeRegistry = (registry: Record) => { + customThemeRegistry = registry; +}; + +const resolveThemeConfig = (theme: string): AppThemeConfiguration | undefined => { + return (appTheme as Record)[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, }; };