Add option to save dynamic manga color theme

This commit is contained in:
schroda
2026-01-02 22:34:37 +01:00
parent bc06d88db0
commit 4eaa701851
5 changed files with 111 additions and 34 deletions

View File

@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] (Preview) ## [Unreleased] (Preview)
### Added
- (**Theme**) Add an option to save the dynamic color theme on the manga page as a custom theme
### Changed ### Changed
- (**Theme**) Improve automatic theme background colors - (**Theme**) Improve automatic theme background colors
- (**Theme**) Adjust "primary color" of dynamic color theme on manga pages - (**Theme**) Adjust "primary color" of dynamic color theme on manga pages

View File

@@ -1156,6 +1156,8 @@
"appearance": { "appearance": {
"manga_dynamic_color_schemes": { "manga_dynamic_color_schemes": {
"description": "Changes the theme colors on the manga page based on the thumbnail", "description": "Changes the theme colors on the manga page based on the thumbnail",
"save": "Save color theme",
"set_active": "Set as active theme",
"title": "Dynamic theme colors on manga page" "title": "Dynamic theme colors on manga page"
}, },
"manga_thumbnail_backdrop": { "manga_thumbnail_backdrop": {

View File

@@ -21,10 +21,17 @@ import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import { AwaitableComponent } from 'awaitable-component'; import { AwaitableComponent } from 'awaitable-component';
import ColorLensIcon from '@mui/icons-material/ColorLens';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { MangaType } from '@/lib/graphql/generated/graphql.ts'; import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { CategorySelect } from '@/features/category/components/CategorySelect.tsx'; import { CategorySelect } from '@/features/category/components/CategorySelect.tsx';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { useAppThemeContext } from '@/features/theme/AppThemeContext.tsx';
import { createAppColorTheme } from '@/features/theme/services/ThemeCreator.ts';
import { getTheme } from '@/features/theme/services/AppThemes.ts';
import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
interface IProps { interface IProps {
manga: Pick<MangaType, 'id' | 'inLibrary' | 'sourceId' | 'title'>; manga: Pick<MangaType, 'id' | 'inLibrary' | 'sourceId' | 'title'>;
@@ -37,6 +44,8 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const theme = useTheme(); const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm')); const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
const { settings } = useMetadataServerSettings();
const { dynamicColor, appTheme, shouldUsePureBlackMode, themeMode } = useAppThemeContext();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
@@ -48,10 +57,37 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
AwaitableComponent.show(CategorySelect, { mangaId: manga.id }); AwaitableComponent.show(CategorySelect, { mangaId: manga.id });
}; };
const saveDynamicColorTheme = () => {
AwaitableComponent.show(ThemeCreationDialog, {
mode: 'create',
appTheme: {
id: '',
getName: () => '',
isCustom: true,
muiTheme: createAppColorTheme(
getTheme(appTheme, settings.customThemes).muiTheme,
dynamicColor,
shouldUsePureBlackMode,
MediaQuery.getThemeMode(themeMode),
),
},
});
};
return ( return (
<> <>
{isLargeScreen && ( {isLargeScreen && (
<> <>
{settings.mangaDynamicColorSchemes && (
<CustomTooltip
title={t('settings.appearance.manga_dynamic_color_schemes.save')}
disabled={refreshing}
>
<IconButton onClick={saveDynamicColorTheme} disabled={refreshing} color="inherit">
<ColorLensIcon />
</IconButton>
</CustomTooltip>
)}
<CustomTooltip title={t('manga.label.reload_from_source')} disabled={refreshing}> <CustomTooltip title={t('manga.label.reload_from_source')} disabled={refreshing}>
<IconButton <IconButton
onClick={() => { onClick={() => {
@@ -127,6 +163,14 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
</ListItemIcon> </ListItemIcon>
<ListItemText>{t('manga.label.reload_from_source')}</ListItemText> <ListItemText>{t('manga.label.reload_from_source')}</ListItemText>
</MenuItem> </MenuItem>
{settings.mangaDynamicColorSchemes && (
<MenuItem onClick={saveDynamicColorTheme}>
<ListItemIcon>
<ColorLensIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('settings.appearance.manga_dynamic_color_schemes.save')}</ListItemText>
</MenuItem>
)}
{manga.inLibrary && [ {manga.inLibrary && [
<MenuItem <MenuItem
key="migrate" key="migrate"

View File

@@ -31,6 +31,8 @@ import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholde
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts'; import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
import { TranslationKey } from '@/base/Base.types.ts'; import { TranslationKey } from '@/base/Base.types.ts';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import { useAppThemeContext } from '@/features/theme/AppThemeContext.tsx';
const baseCustomTheme: AppTheme = { const baseCustomTheme: AppTheme = {
id: '', id: '',
@@ -62,7 +64,7 @@ const baseCustomTheme: AppTheme = {
}, },
}; };
type DialogMode = 'create' | 'edit'; type DialogMode = 'create' | 'edit' | 'save_dynamic';
const dialogModeToTranslationKey: Record< const dialogModeToTranslationKey: Record<
DialogMode, DialogMode,
@@ -88,6 +90,13 @@ const dialogModeToTranslationKey: Record<
success: 'settings.appearance.theme.edit.success', success: 'settings.appearance.theme.edit.success',
failure: 'settings.appearance.theme.edit.failure', failure: 'settings.appearance.theme.edit.failure',
}, },
save_dynamic: {
title: 'settings.appearance.manga_dynamic_color_schemes.save',
button: 'global.button.save',
action: 'settings.appearance.theme.create.action',
success: 'settings.appearance.theme.create.success',
failure: 'settings.appearance.theme.create.failure',
},
}; };
export const ThemeCreationDialog = ({ export const ThemeCreationDialog = ({
@@ -107,6 +116,7 @@ export const ThemeCreationDialog = ({
settings: { customThemes }, settings: { customThemes },
request: { loading, error, refetch }, request: { loading, error, refetch },
} = useMetadataServerSettings(); } = useMetadataServerSettings();
const { setAppTheme } = useAppThemeContext();
const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => { const updateCustomThemes = createUpdateMetadataServerSettings<keyof MetadataThemeSettings>((e) => {
throw e; throw e;
@@ -117,6 +127,7 @@ export const ThemeCreationDialog = ({
const [invalidTheme, setInvalidTheme] = useState(false); const [invalidTheme, setInvalidTheme] = useState(false);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [didThemeChange, setDidThemeChange] = useState(mode === 'create'); const [didThemeChange, setDidThemeChange] = useState(mode === 'create');
const [setAsActiveTheme, setSetAsActiveTheme] = useState(false);
return ( return (
<Dialog open={isVisible} onTransitionExited={onExitComplete} fullWidth maxWidth="md"> <Dialog open={isVisible} onTransitionExited={onExitComplete} fullWidth maxWidth="md">
@@ -214,36 +225,55 @@ export const ThemeCreationDialog = ({
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={() => onDismiss()} color="primary"> <Stack sx={{ width: '100%' }}>
{t('global.button.cancel')} <CheckboxInput
</Button> sx={{ margin: 0 }}
<Button size="small"
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length} label={t('settings.appearance.manga_dynamic_color_schemes.set_active')}
onClick={() => { checked={setAsActiveTheme}
makeToast(t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }), 'info'); onChange={(_, checked) => setSetAsActiveTheme(checked)}
/>
setIsCreating(true); <Stack sx={{ flexDirection: 'row', justifyContent: 'end' }}>
updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme }) <Button autoFocus onClick={() => onDismiss()} color="primary">
.then(() => { {t('global.button.cancel')}
</Button>
<Button
disabled={invalidTheme || invalidName || isCreating || !didThemeChange || !theme.id.length}
onClick={() => {
makeToast( makeToast(
t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }), t(dialogModeToTranslationKey[mode].action, { theme: theme.getName() }),
'success', 'info',
); );
onSubmit();
}) setIsCreating(true);
.catch((updateError) => updateCustomThemes('customThemes', { ...customThemes, [theme.id]: theme })
makeToast( .then(() => {
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }), makeToast(
'error', t(dialogModeToTranslationKey[mode].success, { theme: theme.getName() }),
getErrorMessage(updateError), 'success',
), );
)
.finally(() => setIsCreating(false)); if (setAsActiveTheme) {
}} setAppTheme(theme.getName());
color="primary" }
>
{t(dialogModeToTranslationKey[mode].button)} onSubmit();
</Button> })
.catch((updateError) =>
makeToast(
t(dialogModeToTranslationKey[mode].failure, { theme: theme.getName() }),
'error',
getErrorMessage(updateError),
),
)
.finally(() => setIsCreating(false));
}}
color="primary"
>
{t(dialogModeToTranslationKey[mode].button)}
</Button>
</Stack>
</Stack>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -19,13 +19,13 @@ import { useCallback } from 'react';
import { deepmerge } from '@mui/utils'; import { deepmerge } from '@mui/utils';
import { PaletteBackgroundChannel } from 'node_modules/@mui/material/esm/styles/createThemeWithVars'; import { PaletteBackgroundChannel } from 'node_modules/@mui/material/esm/styles/createThemeWithVars';
import { complement, hsl, parseToHsl } from 'polished'; import { complement, hsl, parseToHsl } from 'polished';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { AppTheme } from '@/features/theme/services/AppThemes.ts'; import { AppTheme } from '@/features/theme/services/AppThemes.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts'; import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { TAppThemeContext, ThemeMode } from '@/features/theme/AppTheme.types.ts'; import { TAppThemeContext, ThemeMode } from '@/features/theme/AppTheme.types.ts';
import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts'; import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts';
import { coerceIn } from '@/lib/HelperFunctions.ts'; import { coerceIn } from '@/lib/HelperFunctions.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
const SCROLLBAR_SIZE = 14; const SCROLLBAR_SIZE = 14;
@@ -108,7 +108,7 @@ const getVibrantColorForTheme = (
return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex; return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex;
}; };
const createAppColorTheme = ( export const createAppColorTheme = (
appTheme: AppTheme['muiTheme'], appTheme: AppTheme['muiTheme'],
dynamicColor: TAppThemeContext['dynamicColor'], dynamicColor: TAppThemeContext['dynamicColor'],
setPureBlackMode: boolean, setPureBlackMode: boolean,
@@ -163,9 +163,7 @@ export const createTheme = (
direction: Direction = 'ltr', direction: Direction = 'ltr',
dynamicColor: TAppThemeContext['dynamicColor'] = null, dynamicColor: TAppThemeContext['dynamicColor'] = null,
) => { ) => {
const systemMode = MediaQuery.getSystemThemeMode(); const mode = MediaQuery.getThemeMode(themeMode);
const mode = themeMode === ThemeMode.SYSTEM ? systemMode : themeMode;
const isDarkMode = mode === ThemeMode.DARK; const isDarkMode = mode === ThemeMode.DARK;
const setPureBlackMode = isDarkMode && pureBlackMode; const setPureBlackMode = isDarkMode && pureBlackMode;