mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-24 19:36:30 +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:
@@ -21,6 +21,7 @@ import {
|
||||
useLanguage,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { initCustomThemes } from '/@/renderer/store/custom-themes.store';
|
||||
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
|
||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
import { WebAudio } from '/@/shared/types/types';
|
||||
@@ -39,6 +40,23 @@ const ipc = isElectron() ? window.api.ipc : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const App = () => {
|
||||
// Custom themes must be loaded (and registered into the shared theme
|
||||
// registry) before the first render of ThemedApp, otherwise a user whose
|
||||
// selected theme is a custom one would flash the default theme first.
|
||||
const [customThemesReady, setCustomThemesReady] = useState(!isElectron());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
|
||||
initCustomThemes()
|
||||
.catch((error) => console.error('Failed to load custom themes', error))
|
||||
.finally(() => setCustomThemesReady(true));
|
||||
}, []);
|
||||
|
||||
if (!customThemesReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ThemedApp />;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,24 +4,28 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings';
|
||||
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useCustomThemes, useCustomThemesStore } from '/@/renderer/store/custom-themes.store';
|
||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { THEME_DATA, useSetColorScheme } from '/@/renderer/themes/use-app-theme';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { ColorInput } from '/@/shared/components/color-input/color-input';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { getAppTheme } from '/@/shared/themes/app-theme';
|
||||
import { AppTheme } from '/@/shared/themes/app-theme-types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
const getThemeSwatchColors = (theme: AppTheme) => {
|
||||
const getThemeSwatchColors = (theme: AppTheme | string) => {
|
||||
const themeConfig = getAppTheme(theme);
|
||||
return {
|
||||
background: themeConfig.colors?.background || 'rgb(0, 0, 0)',
|
||||
@@ -34,13 +38,25 @@ const getThemeSwatchColors = (theme: AppTheme) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getGroupedThemeData = () => {
|
||||
const darkThemes = THEME_DATA.filter((theme) => theme.type === 'dark').sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
const lightThemes = THEME_DATA.filter((theme) => theme.type === 'light').sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
const getGroupedThemeData = (
|
||||
customThemes: { error?: string; id: string; label: string; mode: 'dark' | 'light' }[],
|
||||
) => {
|
||||
const customThemeData = customThemes
|
||||
.filter((theme) => !theme.error)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
type: theme.mode,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
const allThemes = [...THEME_DATA, ...customThemeData];
|
||||
|
||||
const darkThemes = allThemes
|
||||
.filter((theme) => theme.type === 'dark')
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const lightThemes = allThemes
|
||||
.filter((theme) => theme.type === 'light')
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -70,8 +86,7 @@ const ColorSwatch = ({ color }: { color: string }) => {
|
||||
};
|
||||
|
||||
const renderThemeOption = ({ option }: { option: { label: string; value: string } }) => {
|
||||
const themeValue = option.value as AppTheme;
|
||||
const colors = getThemeSwatchColors(themeValue);
|
||||
const colors = getThemeSwatchColors(option.value);
|
||||
|
||||
return (
|
||||
<Group gap="sm" style={{ alignItems: 'center', flex: 1 }}>
|
||||
@@ -86,13 +101,95 @@ const renderThemeOption = ({ option }: { option: { label: string; value: string
|
||||
);
|
||||
};
|
||||
|
||||
const CustomThemesManager = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const customThemes = useCustomThemes();
|
||||
const { openThemesFolder, refresh } = useCustomThemesStore();
|
||||
|
||||
const erroredThemes = customThemes.filter((theme) => theme.error);
|
||||
const warnedThemes = customThemes.filter((theme) => !theme.error && theme.warnings?.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsOptions
|
||||
control={
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
onClick={() => openThemesFolder()}
|
||||
size="compact-md"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.openFolder', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button onClick={() => refresh()} size="compact-md" variant="subtle">
|
||||
{t('common.reload', {
|
||||
defaultValue: 'Reload',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
description="Drop .json theme files into this folder to add custom themes. They're picked up automatically whenever you add, edit, or remove a file."
|
||||
title="Custom Themes"
|
||||
/>
|
||||
{erroredThemes.length > 0 && (
|
||||
<SettingsOptions
|
||||
control={
|
||||
<Stack gap={4}>
|
||||
{erroredThemes.map((theme) => (
|
||||
<Text
|
||||
isNoSelect
|
||||
key={theme.id}
|
||||
size="sm"
|
||||
style={{ color: 'var(--theme-colors-state-error)' }}
|
||||
>
|
||||
{theme.id}: {theme.error}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
description="These theme files could not be loaded."
|
||||
indent
|
||||
title="Errors"
|
||||
/>
|
||||
)}
|
||||
{warnedThemes.length > 0 && (
|
||||
<SettingsOptions
|
||||
control={
|
||||
<Stack gap={4}>
|
||||
{warnedThemes.map((theme) => (
|
||||
<Stack gap={0} key={theme.id}>
|
||||
{theme.warnings?.map((warning) => (
|
||||
<Text
|
||||
isNoSelect
|
||||
key={warning}
|
||||
size="sm"
|
||||
style={{ color: 'var(--theme-colors-state-warning)' }}
|
||||
>
|
||||
{theme.id}: {warning}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
description="These themes loaded, but some values were invalid and were ignored."
|
||||
indent
|
||||
title="Warnings"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const ThemeSettings = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const settings = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const { setColorScheme } = useSetColorScheme();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const groupedThemeData = useMemo(() => getGroupedThemeData(), []);
|
||||
const groupedThemeData = useMemo(() => getGroupedThemeData(customThemes), [customThemes]);
|
||||
|
||||
const themeOptions: SettingOption[] = [
|
||||
{
|
||||
@@ -128,7 +225,7 @@ export const ThemeSettings = memo(() => {
|
||||
data={groupedThemeData}
|
||||
defaultValue={settings.theme}
|
||||
onChange={(e) => {
|
||||
const theme = e as AppTheme;
|
||||
const theme = e as string;
|
||||
|
||||
setSettings({
|
||||
general: {
|
||||
@@ -163,7 +260,7 @@ export const ThemeSettings = memo(() => {
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
themeDark: e as AppTheme,
|
||||
themeDark: e as string,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -185,7 +282,7 @@ export const ThemeSettings = memo(() => {
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
themeLight: e as AppTheme,
|
||||
themeLight: e as string,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -296,7 +393,12 @@ export const ThemeSettings = memo(() => {
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
extra={<StylesSettings />}
|
||||
extra={
|
||||
<>
|
||||
<CustomThemesManager />
|
||||
<StylesSettings />
|
||||
</>
|
||||
}
|
||||
options={themeOptions}
|
||||
title={t('page.setting.theme')}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
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(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generateColors } from '@mantine/colors-generator';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useCustomThemes } from '/@/renderer/store/custom-themes.store';
|
||||
import {
|
||||
useAccent,
|
||||
useFontSettings,
|
||||
@@ -54,6 +55,9 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||
const accent = useAccent();
|
||||
const nativeImageAspect = useNativeAspectRatio();
|
||||
const { builtIn, custom, system, type } = useFontSettings();
|
||||
// Not read directly, but its identity changes whenever the custom
|
||||
// themes folder is reloaded, which is what we want to react to below.
|
||||
const customThemes = useCustomThemes();
|
||||
const textStyleRef = useRef<HTMLStyleElement | null>(null);
|
||||
const themeInlineStylesRef = useRef<HTMLStyleElement | null>(null);
|
||||
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
@@ -178,7 +182,20 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
|
||||
},
|
||||
};
|
||||
}, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);
|
||||
// customThemes is not read directly above, but getAppTheme resolves
|
||||
// custom theme ids through a registry that's mutated in place
|
||||
// whenever the themes folder is reloaded (see custom-themes.store.ts).
|
||||
// Including it here is what makes an edited custom theme's colors
|
||||
// get picked up without a manual reselect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- customThemes is read indirectly via getAppTheme's registry lookup, not referenced directly above
|
||||
}, [
|
||||
accent,
|
||||
customThemes,
|
||||
primaryShade,
|
||||
selectedTheme,
|
||||
useThemeAccentColor,
|
||||
useThemePrimaryShade,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -204,6 +221,7 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
||||
root.style.setProperty('--theme-colors-primary', primaryAtShade);
|
||||
}, [
|
||||
accent,
|
||||
customThemes,
|
||||
isDarkTheme,
|
||||
primaryShade,
|
||||
selectedTheme,
|
||||
@@ -290,6 +308,9 @@ export const useAppThemeColors = () => {
|
||||
const accent = useAccent();
|
||||
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const [isDarkTheme] = useState(getCurrentTheme());
|
||||
// Not read directly, but its identity changes whenever the custom
|
||||
// themes folder is reloaded, which is what we want to react to below.
|
||||
const customThemes = useCustomThemes();
|
||||
const {
|
||||
followSystemTheme,
|
||||
primaryShade,
|
||||
@@ -334,7 +355,15 @@ export const useAppThemeColors = () => {
|
||||
...(effectivePrimaryShade != null && { primaryShade: effectivePrimaryShade }),
|
||||
},
|
||||
};
|
||||
}, [accent, primaryShade, selectedTheme, useThemeAccentColor, useThemePrimaryShade]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- customThemes is read indirectly via getAppTheme's registry lookup, not referenced directly above
|
||||
}, [
|
||||
accent,
|
||||
customThemes,
|
||||
primaryShade,
|
||||
selectedTheme,
|
||||
useThemeAccentColor,
|
||||
useThemePrimaryShade,
|
||||
]);
|
||||
|
||||
const themeVars = useMemo(() => {
|
||||
return Object.entries(appTheme?.app ?? {})
|
||||
|
||||
Reference in New Issue
Block a user