Use dynamic color schemes on manga page
This commit is contained in:
2
src/UtilTypes.d.ts
vendored
2
src/UtilTypes.d.ts
vendored
@@ -20,6 +20,8 @@ type RecursivePartial<T> = {
|
||||
: T[P];
|
||||
};
|
||||
|
||||
type NonNullableProperties<T> = { [P in keyof T]-?: NonNullable<T[P]> };
|
||||
|
||||
type OptionalProperty<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
type PropertiesNever<T> = { [key in keyof T]?: never };
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import { ComponentProps, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ComponentProps, ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { t as translate } from 'i18next';
|
||||
import Link from '@mui/material/Link';
|
||||
@@ -26,6 +26,8 @@ import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import Modal from '@mui/material/Modal';
|
||||
import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { Vibrant } from 'node-vibrant/browser';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
||||
import { Mangas } from '@/modules/manga/services/Mangas.ts';
|
||||
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
|
||||
@@ -40,6 +42,7 @@ import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
|
||||
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
|
||||
import { MANGA_COVER_ASPECT_RATIO, statusToTranslationKey } from '@/modules/manga/Manga.constants.ts';
|
||||
import { MangaThumbnailInfo, MangaTrackRecordInfo } from '@/modules/manga/Manga.types.ts';
|
||||
import { TAppThemeContext, useAppThemeContext } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
||||
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
|
||||
|
||||
const DetailsWrapper = styled('div')(({ theme }) => ({
|
||||
@@ -159,13 +162,57 @@ function getValueOrUnknown(val?: string | null) {
|
||||
return val ?? translate('global.label.unknown');
|
||||
}
|
||||
|
||||
const Thumbnail = ({ manga }: { manga: Partial<MangaThumbnailInfo> }) => {
|
||||
const Thumbnail = ({
|
||||
manga,
|
||||
mangaDynamicColorSchemes,
|
||||
}: {
|
||||
manga: Partial<MangaThumbnailInfo>;
|
||||
mangaDynamicColorSchemes: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { setDynamicColor } = useAppThemeContext();
|
||||
|
||||
const popupState = usePopupState({ variant: 'popover', popupId: 'manga-thumbnail-fullscreen' });
|
||||
|
||||
const [isImageReady, setIsImageReady] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mangaDynamicColorSchemes) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = Mangas.getThumbnailUrl(manga);
|
||||
|
||||
img.onload = () => {
|
||||
Promise.all([
|
||||
Vibrant.from(img).getPalette(),
|
||||
new FastAverageColor().getColorAsync(img, { algorithm: 'sqrt' }),
|
||||
]).then(([palette, averageColor]) => {
|
||||
if (
|
||||
!palette.Vibrant ||
|
||||
!palette.DarkVibrant ||
|
||||
!palette.LightVibrant ||
|
||||
!palette.LightMuted ||
|
||||
!palette.Muted ||
|
||||
!palette.DarkMuted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDynamicColor({
|
||||
...palette,
|
||||
average: averageColor,
|
||||
} as TAppThemeContext['dynamicColor']);
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
setDynamicColor(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
@@ -313,7 +360,7 @@ export const MangaDetails = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
settings: { mangaThumbnailBackdrop },
|
||||
settings: { mangaThumbnailBackdrop, mangaDynamicColorSchemes },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -338,7 +385,7 @@ export const MangaDetails = ({
|
||||
<DetailsWrapper>
|
||||
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
|
||||
<ThumbnailMetadataWrapper>
|
||||
<Thumbnail manga={manga} />
|
||||
<Thumbnail manga={manga} mangaDynamicColorSchemes={mangaDynamicColorSchemes} />
|
||||
<MetadataContainer>
|
||||
<Stack sx={{ flexDirection: 'row', gap: 1, alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="h5" component="h2" sx={{ wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -50,6 +50,7 @@ const APP_METADATA_OBJECT: Record<AppMetadataKeys, undefined> = {
|
||||
hasStatus: undefined,
|
||||
customThemes: undefined,
|
||||
mangaThumbnailBackdrop: undefined,
|
||||
mangaDynamicColorSchemes: undefined,
|
||||
tapZoneLayout: undefined,
|
||||
tapZoneInvertMode: undefined,
|
||||
readingDirection: undefined,
|
||||
@@ -141,6 +142,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
|
||||
// themes
|
||||
'customThemes',
|
||||
'mangaThumbnailBackdrop',
|
||||
'mangaDynamicColorSchemes',
|
||||
|
||||
// reader
|
||||
'exitMode',
|
||||
|
||||
@@ -52,4 +52,5 @@ export const SERVER_SETTINGS_METADATA_DEFAULT: MetadataServerSettings = {
|
||||
// themes
|
||||
customThemes: {},
|
||||
mangaThumbnailBackdrop: true,
|
||||
mangaDynamicColorSchemes: true,
|
||||
};
|
||||
|
||||
@@ -184,6 +184,18 @@ export const Appearance = () => {
|
||||
onChange={(e) => updateMetadataSetting('mangaThumbnailBackdrop', e.target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('settings.appearance.manga_dynamic_color_schemes.title')}
|
||||
secondary={t('settings.appearance.manga_dynamic_color_schemes.description')}
|
||||
/>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={settings.mangaDynamicColorSchemes}
|
||||
onChange={(e) => updateMetadataSetting('mangaDynamicColorSchemes', e.target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -11,4 +11,5 @@ import { AppTheme } from '@/modules/theme/services/AppThemes.ts';
|
||||
export type MetadataThemeSettings = {
|
||||
customThemes: Record<string, AppTheme>;
|
||||
mangaThumbnailBackdrop: boolean;
|
||||
mangaDynamicColorSchemes: boolean;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { Palette } from '@vibrant/color';
|
||||
import { FastAverageColorResult } from 'fast-average-color';
|
||||
import { AppThemes } from '@/modules/theme/services/AppThemes.ts';
|
||||
|
||||
export enum ThemeMode {
|
||||
@@ -22,6 +24,10 @@ export type TAppThemeContext = {
|
||||
setThemeMode: React.Dispatch<React.SetStateAction<ThemeMode>>;
|
||||
pureBlackMode: boolean;
|
||||
setPureBlackMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
dynamicColor: (NonNullableProperties<Palette> & { average: FastAverageColorResult }) | null;
|
||||
setDynamicColor: React.Dispatch<
|
||||
React.SetStateAction<(NonNullableProperties<Palette> & { average: FastAverageColorResult }) | null>
|
||||
>;
|
||||
};
|
||||
|
||||
export const AppThemeContext = React.createContext<TAppThemeContext>({
|
||||
@@ -31,4 +37,8 @@ export const AppThemeContext = React.createContext<TAppThemeContext>({
|
||||
setThemeMode: (): void => {},
|
||||
pureBlackMode: false,
|
||||
setPureBlackMode: (): void => {},
|
||||
dynamicColor: null,
|
||||
setDynamicColor: (): void => {},
|
||||
});
|
||||
|
||||
export const useAppThemeContext = () => useContext(AppThemeContext);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Direction, ThemeProvider, useColorScheme } from '@mui/material/styles';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppThemeContext, ThemeMode } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
||||
import { AppThemeContext, TAppThemeContext, ThemeMode } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
||||
import { DIRECTION_TO_CACHE } from '@/modules/theme/ThemeDirectionCache.ts';
|
||||
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
|
||||
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
|
||||
@@ -31,6 +31,7 @@ export const AppThemeContextProvider = ({ children }: { children: ReactNode }) =
|
||||
const directionRef = useRef<Direction>('ltr');
|
||||
|
||||
const [systemThemeMode, setSystemThemeMode] = useState<ThemeMode>(MediaQuery.getSystemThemeMode());
|
||||
const [dynamicColor, setDynamicColor] = useState<TAppThemeContext['dynamicColor']>(null);
|
||||
|
||||
const actualThemeMode = mode ?? themeMode ?? 'dark';
|
||||
const currentDirection = i18n.dir();
|
||||
@@ -43,8 +44,10 @@ export const AppThemeContextProvider = ({ children }: { children: ReactNode }) =
|
||||
setThemeMode,
|
||||
pureBlackMode,
|
||||
setPureBlackMode,
|
||||
dynamicColor,
|
||||
setDynamicColor,
|
||||
}),
|
||||
[themeMode, pureBlackMode, appTheme],
|
||||
[themeMode, pureBlackMode, appTheme, dynamicColor],
|
||||
);
|
||||
|
||||
const theme = useMemo(
|
||||
@@ -54,8 +57,9 @@ export const AppThemeContextProvider = ({ children }: { children: ReactNode }) =
|
||||
getTheme(appTheme, customThemes),
|
||||
pureBlackMode,
|
||||
currentDirection,
|
||||
dynamicColor,
|
||||
),
|
||||
[actualThemeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes],
|
||||
[actualThemeMode, currentDirection, systemThemeMode, pureBlackMode, appTheme, customThemes, dynamicColor],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -21,7 +21,8 @@ import { useCallback } from 'react';
|
||||
import { deepmerge } from '@mui/utils';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PaletteBackgroundChannel } from '@mui/material/styles/createThemeWithVars';
|
||||
import { ThemeMode } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
||||
import { Palette } from '@vibrant/color';
|
||||
import { TAppThemeContext, ThemeMode } from '@/modules/theme/contexts/AppThemeContext.tsx';
|
||||
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
|
||||
import { AppTheme, loadThemeFonts } from '@/modules/theme/services/AppThemes.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
@@ -67,12 +68,123 @@ const getBackgroundColor = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createAppThemeWithDynamicPrimaryColor = (
|
||||
primaryColor: string | null | undefined,
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
): AppTheme['muiTheme'] => {
|
||||
if (!primaryColor) {
|
||||
return appTheme;
|
||||
}
|
||||
|
||||
return {
|
||||
...appTheme,
|
||||
colorSchemes: {
|
||||
light: { palette: { primary: { main: primaryColor } } },
|
||||
dark: { palette: { primary: { main: primaryColor } } },
|
||||
},
|
||||
} satisfies AppTheme['muiTheme'];
|
||||
};
|
||||
|
||||
const getHighestPopulationColor = (
|
||||
palette: NonNullableProperties<Palette> | null,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): string | null => {
|
||||
if (!palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let highestPopulation = 0;
|
||||
let highestPopulationKey: string = '';
|
||||
let highestPopulationColor: string = '';
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const key in palette) {
|
||||
const swatch = palette[key];
|
||||
|
||||
if (swatch.population > highestPopulation) {
|
||||
highestPopulationKey = key;
|
||||
highestPopulation = swatch.population;
|
||||
highestPopulationColor = swatch.hex;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ThemeMode.LIGHT) {
|
||||
if (highestPopulationKey.startsWith('Light')) {
|
||||
return palette[highestPopulationKey.replace('Light', 'Dark')].hex;
|
||||
}
|
||||
|
||||
if (!highestPopulationKey.startsWith('Dark')) {
|
||||
return palette[`Dark${highestPopulationKey}`].hex;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ThemeMode.DARK) {
|
||||
if (highestPopulationKey.startsWith('Dark')) {
|
||||
return palette[highestPopulationKey.replace('Dark', 'Light')].hex;
|
||||
}
|
||||
|
||||
if (!highestPopulationKey.startsWith('Light')) {
|
||||
return palette[`Light${highestPopulationKey}`].hex;
|
||||
}
|
||||
}
|
||||
|
||||
return highestPopulationColor;
|
||||
};
|
||||
|
||||
const createAppColorTheme = (
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
dynamicColor: TAppThemeContext['dynamicColor'],
|
||||
setPureBlackMode: boolean,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): AppTheme['muiTheme'] => {
|
||||
const appThemeWithDominantPrimaryColor = createAppThemeWithDynamicPrimaryColor(dynamicColor?.average.hex, appTheme);
|
||||
const themePrimaryColorForBackground = createMuiTheme({
|
||||
...appThemeWithDominantPrimaryColor,
|
||||
defaultColorScheme: mode,
|
||||
});
|
||||
|
||||
const themeBackgroundColor = deepmerge(appThemeWithDominantPrimaryColor, {
|
||||
defaultColorScheme: mode,
|
||||
colorSchemes: {
|
||||
light: themePrimaryColorForBackground.colorSchemes?.light
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'light',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
dark: themePrimaryColorForBackground.colorSchemes?.dark
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'dark',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
setPureBlackMode,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appThemeWithVibrantPrimaryColor = createAppThemeWithDynamicPrimaryColor(
|
||||
getHighestPopulationColor(dynamicColor, mode),
|
||||
themeBackgroundColor,
|
||||
);
|
||||
return deepmerge(themeBackgroundColor, appThemeWithVibrantPrimaryColor);
|
||||
};
|
||||
|
||||
export const createTheme = (
|
||||
themeMode: ThemeMode,
|
||||
appTheme: AppTheme,
|
||||
pureBlackMode: boolean = false,
|
||||
direction: Direction = 'ltr',
|
||||
dynamicColor: string | null = null,
|
||||
dynamicColor: TAppThemeContext['dynamicColor'] = null,
|
||||
) => {
|
||||
const systemMode = MediaQuery.getSystemThemeMode();
|
||||
|
||||
@@ -80,35 +192,14 @@ export const createTheme = (
|
||||
const isDarkMode = mode === ThemeMode.DARK;
|
||||
const setPureBlackMode = isDarkMode && pureBlackMode;
|
||||
|
||||
const themeDynamicColor = {
|
||||
...appTheme.muiTheme,
|
||||
light: { palette: { primary: { main: dynamicColor } } },
|
||||
dark: { palette: { primary: { main: dynamicColor } } },
|
||||
};
|
||||
const themeFinalColor = dynamicColor ? themeDynamicColor : appTheme.muiTheme;
|
||||
const appColorTheme = createAppColorTheme(appTheme.muiTheme, dynamicColor, setPureBlackMode, mode);
|
||||
|
||||
const themeForColors = createMuiTheme({ ...appTheme.muiTheme, defaultColorScheme: mode });
|
||||
const themeForColors = createMuiTheme({ ...appColorTheme, defaultColorScheme: mode });
|
||||
|
||||
const suwayomiTheme = createMuiTheme(
|
||||
deepmerge(appTheme.muiTheme, {
|
||||
deepmerge(appColorTheme, {
|
||||
defaultColorScheme: mode,
|
||||
direction,
|
||||
colorSchemes: {
|
||||
light: appTheme.muiTheme.colorSchemes?.light
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor('light', themeFinalColor, themeForColors),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
dark: appTheme.muiTheme.colorSchemes?.dark
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor('dark', themeFinalColor, themeForColors, setPureBlackMode),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
components: {
|
||||
...appTheme.muiTheme.components,
|
||||
MuiUseMediaQuery: {
|
||||
|
||||
Reference in New Issue
Block a user