mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 10:26:33 +02:00
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:
@@ -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).
|
Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin).
|
||||||
|
|
||||||
Alternatively, you can install it as an Appimage.
|
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.
|
||||||
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:
|
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)
|
- [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.
|
- [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
|
### 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.
|
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.
|
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
|
## Development
|
||||||
|
|
||||||
Built and tested using Node `v23.11.0`.
|
Built and tested using Node `v23.11.0`.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -141,6 +141,7 @@
|
|||||||
"semver": "^7.8.2",
|
"semver": "^7.8.2",
|
||||||
"string-to-color": "^2.2.2",
|
"string-to-color": "^2.2.2",
|
||||||
"taglib-wasm": "^1.5.1",
|
"taglib-wasm": "^1.5.1",
|
||||||
|
"validate-color": "^2.2.4",
|
||||||
"wavesurfer.js": "^7.12.7",
|
"wavesurfer.js": "^7.12.7",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
|
|||||||
Generated
+8
@@ -219,6 +219,9 @@ importers:
|
|||||||
taglib-wasm:
|
taglib-wasm:
|
||||||
specifier: ^1.5.1
|
specifier: ^1.5.1
|
||||||
version: 1.5.1(typescript@5.9.3)
|
version: 1.5.1(typescript@5.9.3)
|
||||||
|
validate-color:
|
||||||
|
specifier: ^2.2.4
|
||||||
|
version: 2.2.4
|
||||||
wavesurfer.js:
|
wavesurfer.js:
|
||||||
specifier: ^7.12.7
|
specifier: ^7.12.7
|
||||||
version: 7.12.8
|
version: 7.12.8
|
||||||
@@ -5443,6 +5446,9 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
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:
|
value-or-function@4.0.0:
|
||||||
resolution: {integrity: sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==}
|
resolution: {integrity: sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==}
|
||||||
engines: {node: '>= 10.13.0'}
|
engines: {node: '>= 10.13.0'}
|
||||||
@@ -11321,6 +11327,8 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
validate-color@2.2.4: {}
|
||||||
|
|
||||||
value-or-function@4.0.0: {}
|
value-or-function@4.0.0: {}
|
||||||
|
|
||||||
vinyl-contents@2.0.0:
|
vinyl-contents@2.0.0:
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import './autodiscover';
|
import './autodiscover';
|
||||||
|
import './custom-themes';
|
||||||
import './lyrics';
|
import './lyrics';
|
||||||
import './player';
|
import './player';
|
||||||
import './remote';
|
import './remote';
|
||||||
|
|||||||
@@ -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,6 +2,7 @@ import { contextBridge, webUtils } from 'electron';
|
|||||||
|
|
||||||
import { autodiscover } from './autodiscover';
|
import { autodiscover } from './autodiscover';
|
||||||
import { browser } from './browser';
|
import { browser } from './browser';
|
||||||
|
import { customThemes } from './custom-themes';
|
||||||
import { discordRpc } from './discord-rpc';
|
import { discordRpc } from './discord-rpc';
|
||||||
import { ipc } from './ipc';
|
import { ipc } from './ipc';
|
||||||
import { localSettings } from './local-settings';
|
import { localSettings } from './local-settings';
|
||||||
@@ -16,6 +17,7 @@ import { visualizer } from './visualizer';
|
|||||||
const api = {
|
const api = {
|
||||||
autodiscover,
|
autodiscover,
|
||||||
browser,
|
browser,
|
||||||
|
customThemes,
|
||||||
discordRpc,
|
discordRpc,
|
||||||
getPathForFile: webUtils.getPathForFile,
|
getPathForFile: webUtils.getPathForFile,
|
||||||
ipc,
|
ipc,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
useLanguage,
|
useLanguage,
|
||||||
useSettingsStoreActions,
|
useSettingsStoreActions,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
|
import { initCustomThemes } from '/@/renderer/store/custom-themes.store';
|
||||||
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
||||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||||
import { WebAudio } from '/@/shared/types/types';
|
import { WebAudio } from '/@/shared/types/types';
|
||||||
@@ -39,6 +40,23 @@ const ipc = isElectron() ? window.api.ipc : null;
|
|||||||
const utils = isElectron() ? window.api.utils : null;
|
const utils = isElectron() ? window.api.utils : null;
|
||||||
|
|
||||||
export const App = () => {
|
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 />;
|
return <ThemedApp />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,28 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
|
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
|
||||||
|
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
|
||||||
import {
|
import {
|
||||||
SettingOption,
|
SettingOption,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} 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 { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||||
import { THEME_DATA, useSetColorScheme } from '/@/renderer/themes/use-app-theme';
|
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 { ColorInput } from '/@/shared/components/color-input/color-input';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Select } from '/@/shared/components/select/select';
|
import { Select } from '/@/shared/components/select/select';
|
||||||
import { Slider } from '/@/shared/components/slider/slider';
|
import { Slider } from '/@/shared/components/slider/slider';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { getAppTheme } from '/@/shared/themes/app-theme';
|
import { getAppTheme } from '/@/shared/themes/app-theme';
|
||||||
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
||||||
|
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
|
|
||||||
const getThemeSwatchColors = (theme: AppTheme) => {
|
const getThemeSwatchColors = (theme: AppTheme | string) => {
|
||||||
const themeConfig = getAppTheme(theme);
|
const themeConfig = getAppTheme(theme);
|
||||||
return {
|
return {
|
||||||
background: themeConfig.colors?.background || 'rgb(0, 0, 0)',
|
background: themeConfig.colors?.background || 'rgb(0, 0, 0)',
|
||||||
@@ -34,13 +38,25 @@ const getThemeSwatchColors = (theme: AppTheme) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGroupedThemeData = () => {
|
const getGroupedThemeData = (
|
||||||
const darkThemes = THEME_DATA.filter((theme) => theme.type === 'dark').sort((a, b) =>
|
customThemes: { error?: string; id: string; label: string; mode: 'dark' | 'light' }[],
|
||||||
a.label.localeCompare(b.label),
|
) => {
|
||||||
);
|
const customThemeData = customThemes
|
||||||
const lightThemes = THEME_DATA.filter((theme) => theme.type === 'light').sort((a, b) =>
|
.filter((theme) => !theme.error)
|
||||||
a.label.localeCompare(b.label),
|
.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 [
|
return [
|
||||||
{
|
{
|
||||||
@@ -70,8 +86,7 @@ const ColorSwatch = ({ color }: { color: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderThemeOption = ({ option }: { option: { label: string; value: string } }) => {
|
const renderThemeOption = ({ option }: { option: { label: string; value: string } }) => {
|
||||||
const themeValue = option.value as AppTheme;
|
const colors = getThemeSwatchColors(option.value);
|
||||||
const colors = getThemeSwatchColors(themeValue);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="sm" style={{ alignItems: 'center', flex: 1 }}>
|
<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(() => {
|
export const ThemeSettings = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settings = useGeneralSettings();
|
const settings = useGeneralSettings();
|
||||||
const { setSettings } = useSettingsStoreActions();
|
const { setSettings } = useSettingsStoreActions();
|
||||||
const { setColorScheme } = useSetColorScheme();
|
const { setColorScheme } = useSetColorScheme();
|
||||||
|
const customThemes = useCustomThemes();
|
||||||
|
|
||||||
const groupedThemeData = useMemo(() => getGroupedThemeData(), []);
|
const groupedThemeData = useMemo(() => getGroupedThemeData(customThemes), [customThemes]);
|
||||||
|
|
||||||
const themeOptions: SettingOption[] = [
|
const themeOptions: SettingOption[] = [
|
||||||
{
|
{
|
||||||
@@ -128,7 +225,7 @@ export const ThemeSettings = memo(() => {
|
|||||||
data={groupedThemeData}
|
data={groupedThemeData}
|
||||||
defaultValue={settings.theme}
|
defaultValue={settings.theme}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const theme = e as AppTheme;
|
const theme = e as string;
|
||||||
|
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
@@ -163,7 +260,7 @@ export const ThemeSettings = memo(() => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
themeDark: e as AppTheme,
|
themeDark: e as string,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -185,7 +282,7 @@ export const ThemeSettings = memo(() => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
themeLight: e as AppTheme,
|
themeLight: e as string,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -296,7 +393,12 @@ export const ThemeSettings = memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
extra={<StylesSettings />}
|
extra={
|
||||||
|
<>
|
||||||
|
<CustomThemesManager />
|
||||||
|
<StylesSettings />
|
||||||
|
</>
|
||||||
|
}
|
||||||
options={themeOptions}
|
options={themeOptions}
|
||||||
title={t('page.setting.theme')}
|
title={t('page.setting.theme')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -557,9 +557,14 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
sideQueueType: SideQueueTypeSchema,
|
sideQueueType: SideQueueTypeSchema,
|
||||||
skipButtons: SkipButtonsSchema,
|
skipButtons: SkipButtonsSchema,
|
||||||
spotify: z.boolean(),
|
spotify: z.boolean(),
|
||||||
theme: z.nativeEnum(AppTheme),
|
// Accepts either a built-in AppTheme id or a custom theme id (the
|
||||||
themeDark: z.nativeEnum(AppTheme),
|
// filename, without extension, of a JSON file in the themes folder).
|
||||||
themeLight: z.nativeEnum(AppTheme),
|
// 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(),
|
useThemeAccentColor: z.boolean(),
|
||||||
useThemePrimaryShade: z.boolean(),
|
useThemePrimaryShade: z.boolean(),
|
||||||
volumeWheelStep: z.number(),
|
volumeWheelStep: z.number(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generateColors } from '@mantine/colors-generator';
|
|||||||
import { useMantineColorScheme } from '@mantine/core';
|
import { useMantineColorScheme } from '@mantine/core';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useCustomThemes } from '/@/renderer/store/custom-themes.store';
|
||||||
import {
|
import {
|
||||||
useAccent,
|
useAccent,
|
||||||
useFontSettings,
|
useFontSettings,
|
||||||
@@ -54,6 +55,9 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
|||||||
const accent = useAccent();
|
const accent = useAccent();
|
||||||
const nativeImageAspect = useNativeAspectRatio();
|
const nativeImageAspect = useNativeAspectRatio();
|
||||||
const { builtIn, custom, system, type } = useFontSettings();
|
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 textStyleRef = useRef<HTMLStyleElement | null>(null);
|
||||||
const themeInlineStylesRef = useRef<HTMLStyleElement | null>(null);
|
const themeInlineStylesRef = useRef<HTMLStyleElement | null>(null);
|
||||||
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@@ -178,7 +182,20 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
|||||||
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
|
...(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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -204,6 +221,7 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
|||||||
root.style.setProperty('--theme-colors-primary', primaryAtShade);
|
root.style.setProperty('--theme-colors-primary', primaryAtShade);
|
||||||
}, [
|
}, [
|
||||||
accent,
|
accent,
|
||||||
|
customThemes,
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
primaryShade,
|
primaryShade,
|
||||||
selectedTheme,
|
selectedTheme,
|
||||||
@@ -290,6 +308,9 @@ export const useAppThemeColors = () => {
|
|||||||
const accent = useAccent();
|
const accent = useAccent();
|
||||||
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
const [isDarkTheme] = useState(getCurrentTheme());
|
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 {
|
const {
|
||||||
followSystemTheme,
|
followSystemTheme,
|
||||||
primaryShade,
|
primaryShade,
|
||||||
@@ -334,7 +355,15 @@ export const useAppThemeColors = () => {
|
|||||||
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
|
...(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(() => {
|
const themeVars = useMemo(() => {
|
||||||
return Object.entries(appTheme?.app ?? {})
|
return Object.entries(appTheme?.app ?? {})
|
||||||
|
|||||||
@@ -72,12 +72,28 @@ export const appTheme: Record<AppTheme, AppThemeConfiguration> = {
|
|||||||
[AppTheme.ZENBURN]: zenburn,
|
[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 {
|
return {
|
||||||
app: merge({}, defaultTheme.app, appTheme[theme].app),
|
app: merge({}, defaultTheme.app, themeConfig.app),
|
||||||
colors: merge({}, defaultTheme.colors, appTheme[theme].colors),
|
colors: merge({}, defaultTheme.colors, themeConfig.colors),
|
||||||
mantineOverride: merge({}, defaultTheme.mantineOverride, appTheme[theme].mantineOverride),
|
mantineOverride: merge({}, defaultTheme.mantineOverride, themeConfig.mantineOverride),
|
||||||
mode: appTheme[theme].mode,
|
mode: themeConfig.mode,
|
||||||
stylesheets: appTheme[theme].stylesheets,
|
stylesheets: themeConfig.stylesheets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user