Add auto reader background color

This commit is contained in:
schroda
2026-07-15 22:34:51 +02:00
parent 7559676e68
commit 3562f5f647
18 changed files with 440 additions and 45 deletions

View File

@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Settings/WebView**) Add setting to enable/disable WebView
- (**Settings/Sync**) Add sync settings
- (**Extension**) Support installing external JARs
- (**Reader**) Add auto background color setting
### Changed

View File

@@ -54,6 +54,7 @@
"@vibrant/color": "4.0.4",
"apollo-upload-client": "19.0.0",
"awaitable-component": "1.0.0",
"colorthief": "3.4.0",
"csstype": "3.2.3",
"dayjs": "1.11.21",
"fast-average-color": "9.5.2",

14
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
awaitable-component:
specifier: 1.0.0
version: 1.0.0(react@19.2.6)
colorthief:
specifier: 3.4.0
version: 3.4.0
csstype:
specifier: 3.2.3
version: 3.2.3
@@ -3238,6 +3241,15 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorthief@3.4.0:
resolution: {integrity: sha512-mwXc0OHLsb1on2JtVD2go31GTyHwBLtkRruggz6H/bY2vI1mL8v8Mvam9qIWiI4khnsbQXoBs8M+EofnlcQitQ==}
hasBin: true
peerDependencies:
sharp: '>=0.33.0'
peerDependenciesMeta:
sharp:
optional: true
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
@@ -8668,6 +8680,8 @@ snapshots:
color-name@1.1.4: {}
colorthief@3.4.0: {}
commander@10.0.1: {}
commander@14.0.3: {}

View File

@@ -296,6 +296,9 @@ export const APP_METADATA: Record<
backgroundColor: {
convert: convertToNumber, // ReaderBackgroundColor (enum)
},
useAutoBackgroundColorContinuousMode: {
convert: convertToBoolean,
},
pageGap: {
convert: convertToNumber,
toConstrainedValue: (value: number) => coerceIn(value, PAGE_GAP.min, PAGE_GAP.max),

View File

@@ -69,6 +69,7 @@ export enum ReaderBackgroundColor {
BLACK,
GRAY,
WHITE,
AUTO,
}
export interface ReaderFilterRGBA {
@@ -159,6 +160,7 @@ export interface IReaderSettingsGlobal {
shouldShowPageNumber: boolean;
isStaticNav: boolean;
backgroundColor: ReaderBackgroundColor;
useAutoBackgroundColorContinuousMode: boolean;
hotkeys: Record<ReaderHotkey, string[]>;
imagePreLoadAmount: number;
shouldUseAutoWebtoonMode: boolean;
@@ -321,6 +323,10 @@ export interface ReaderStatePages {
setPageLoadStates: (
set: ((prevStates: ReaderPageLoadState[]) => ReaderPageLoadState[]) | ReaderPageLoadState[],
) => void;
pageBackgroundColors: ReaderPageBackgroundColor[];
setPageBackgroundColor: (
set: ((prev: ReaderPageBackgroundColor[]) => ReaderPageBackgroundColor[]) | ReaderPageBackgroundColor[],
) => void;
pages: PageData[];
setPages: (pages: PageData[]) => void;
transitionPageMode: ReaderTransitionPageMode;
@@ -395,3 +401,5 @@ export type TReaderStateSettingsContext = {
};
export type ReaderPageSpreadState = { url: string; isSpread: boolean };
export type ReaderPageBackgroundColor = { url: string; color: string | undefined };

View File

@@ -14,8 +14,17 @@ import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { CHAPTER_READER_FIELDS } from '@/lib/graphql/chapter/ChapterFragments.ts';
import { isPageOfOutdatedPageLoadStates, isSpreadPage } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { coerceIn } from '@/lib/HelperFunctions.ts';
import { DirectionOffset } from '@/base/Base.types.ts';
import * as ColorThief from 'colorthief';
import groupBy from 'lodash/fp/groupBy';
import mapValues from 'lodash/fp/mapValues';
import maxBy from 'lodash/fp/maxBy';
import sumBy from 'lodash/fp/sumBy';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
export const getInitialReaderPageIndex = (
resumeMode: ReaderResumeMode,
@@ -124,11 +133,135 @@ export const getChapterIdsForDownloadAhead = (
.filter((id) => !Chapters.isDownloading(id));
};
const getPageBackgroundColor = (
[top, right, bottom, left]: [
Top: ColorThief.Color[] | null,
Right: ColorThief.Color[] | null,
Bottom: ColorThief.Color[] | null,
Left: ColorThief.Color[] | null,
],
readingMode: ReadingMode,
): string => {
const isContinuousReadingModeFlag = isContinuousReadingMode(readingMode);
const isContinuousVerticalReadingModeFlag = isContinuousVerticalReadingMode(readingMode);
const borderColorPalettes = (() => {
if (isContinuousVerticalReadingModeFlag) {
return [right, left];
}
if (isContinuousReadingModeFlag) {
return [top, bottom];
}
return [top, right, bottom, left];
})();
const blackThreshold = 10;
const whiteThreshold = 246;
const considerBlack = (color: ColorThief.Color | null): boolean => {
if (!color) {
return true;
}
const { r, g, b } = color.rgb();
return r <= blackThreshold && g <= blackThreshold && b <= blackThreshold;
};
const considerWhite = (color: ColorThief.Color | null): boolean => {
if (!color) {
return true;
}
const { r, g, b } = color.rgb();
return r >= whiteThreshold && g >= whiteThreshold && b >= whiteThreshold;
};
const getHexValue = (color: ColorThief.Color | null) => {
if (considerBlack(color)) {
return '#000000';
}
if (considerWhite(color)) {
return '#ffffff';
}
return color!.hex();
};
const colorsByHexValue = groupBy((color) => getHexValue(color), borderColorPalettes.flat().filter(Boolean));
const proportionByHexValue = mapValues(
(colors) =>
sumBy((color) => {
const fillsWholeBorder = color!.proportion >= 0.97;
const multiplier = fillsWholeBorder ? borderColorPalettes.length : 1;
return color!.proportion * multiplier;
}, colors),
colorsByHexValue,
);
const [hexValue] = maxBy(([_hex, proportion]) => proportion, Object.entries(proportionByHexValue))!;
return hexValue;
};
const updatePageBackgroundColor = async (
index: number,
url: string,
img: HTMLImageElement,
setPageBackgroundColors: ReaderStatePages['setPageBackgroundColor'],
readingMode: ReadingMode,
): Promise<void> => {
const canvas = new OffscreenCanvas(img.width, img.height);
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(img, 0, 0);
const yOffset = 5;
const xOffset = Math.trunc(img.width * 0.0275);
const borderSampleSize = 1;
const options = { ignoreWhite: false };
const borderColorPalettes = await (async () => {
try {
return await Promise.all([
ColorThief.getPalette(ctx.getImageData(0, yOffset, img.width, borderSampleSize), options),
ColorThief.getPalette(ctx.getImageData(img.width - xOffset, 0, borderSampleSize, img.height), options),
ColorThief.getPalette(ctx.getImageData(0, img.height - yOffset, img.width, borderSampleSize), options),
ColorThief.getPalette(ctx.getImageData(xOffset, 0, borderSampleSize, img.height), options),
]);
} catch (e) {
return null;
}
})();
if (!borderColorPalettes) {
return;
}
setPageBackgroundColors((prevState) => {
const pageBackgroundColor = prevState[index];
if (isPageOfOutdatedPageLoadStates(url, pageBackgroundColor)) {
return prevState;
}
if (pageBackgroundColor.color) {
return prevState;
}
const color = getPageBackgroundColor(borderColorPalettes, readingMode);
return prevState.toSpliced(index, 1, { url, color });
});
};
export const createUpdateReaderPageLoadState =
(
actualPages: ReaderStatePages['pages'],
setPagesToSpreadState: React.Dispatch<React.SetStateAction<ReaderPageSpreadState[]>>,
setPageLoadStates: ReaderStatePages['setPageLoadStates'],
setPageBackgroundColors: ReaderStatePages['setPageBackgroundColor'],
readingMode: ReadingMode,
) =>
(pagesIndex: number, url: string, isPrimary: boolean = true) => {
@@ -139,9 +272,13 @@ export const createUpdateReaderPageLoadState =
const page = actualPages[pagesIndex];
const { index } = isPrimary ? page.primary : page.secondary!;
if (readingMode === ReadingMode.DOUBLE_PAGE) {
const img = new Image();
img.onload = () => {
updatePageBackgroundColor(index, url, img, setPageBackgroundColors, readingMode).catch(
defaultPromiseErrorHandler('updatePageBackgroundColor'),
);
if (readingMode === ReadingMode.DOUBLE_PAGE) {
const isSpreadPageFlag = isSpreadPage(img);
if (!isSpreadPageFlag) {
return;
@@ -161,10 +298,10 @@ export const createUpdateReaderPageLoadState =
return prevState.toSpliced(index, 1, { url, isSpread: isSpreadPageFlag });
});
}
};
img.crossOrigin = 'anonymous';
img.src = url;
}
setPageLoadStates((statePageLoadStates) => {
const pageLoadState = statePageLoadStates[index];

View File

@@ -23,7 +23,6 @@ import { GET_CHAPTERS_READER } from '@/lib/graphql/chapter/ChapterQuery.ts';
import { TapZoneLayout } from '@/features/reader/tap-zones/TapZoneLayout.tsx';
import { ReaderRGBAFilter } from '@/features/reader/filters/ReaderRGBAFilter.tsx';
import { ReaderViewer } from '@/features/reader/viewer/ReaderViewer.tsx';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { ReaderHotkeys } from '@/features/reader/hotkeys/ReaderHotkeys.tsx';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import type { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
@@ -39,16 +38,24 @@ import {
getReaderOverlayStore,
getReaderStore,
useReaderChaptersStore,
useReaderPagesStore,
useReaderSettingsStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { ReaderAutoScroll } from '@/features/reader/auto-scroll/ReaderAutoScroll.tsx';
import { getReaderBackgroundColor, isContinuousReadingMode } from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { useTheme } from '@mui/material/styles';
const BaseReader = ({
setOverride,
readerNavBarWidth,
}: Pick<NavbarContextType, 'setOverride' | 'readerNavBarWidth'>) => {
const { t } = useLingui();
const theme = useTheme();
const manga = useReaderStore('manga');
const { mangaChapters, initialChapter, chapterForDuplicatesHandling, currentChapter } = useReaderChaptersStore(
'mangaChapters',
@@ -60,22 +67,31 @@ const BaseReader = ({
shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor,
readingMode,
tapZoneLayout,
tapZoneInvertMode,
shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview,
useAutoBackgroundColorContinuousMode,
} = useReaderSettingsStore(
'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'backgroundColor',
'readingMode',
'tapZoneLayout',
'tapZoneInvertMode',
'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview',
'useAutoBackgroundColorContinuousMode',
);
const safeAreaInset = useReaderSettingsStore((state) => state.safeAreaInset);
const readingMode = useReaderSettingsStore('readingMode');
const readingDirection = useReaderSettingsStore('readingDirection');
const tapZoneLayout = useReaderSettingsStore('tapZoneLayout');
const tapZoneInvertMode = useReaderSettingsStore('tapZoneInvertMode');
const safeAreaInset = useReaderSettingsStore('safeAreaInset');
const { invertColors, applySepia, applyGrayscale } = useReaderSettingsStore((state) => ({
invertColors: state.customFilter.invert,
applySepia: state.customFilter.sepia,
applyGrayscale: state.customFilter.grayscale,
}));
const currentPageIndex = useReaderPagesStore('currentPageIndex');
const pages = useReaderPagesStore('pages');
const pageBackgroundColors = useReaderPagesStore('pageBackgroundColors');
const scrollElementRef = useRef<HTMLDivElement | null>(null);
@@ -112,6 +128,30 @@ const BaseReader = ({
defaultSettingsResponse.loading;
const error = mangaResponse.error ?? chaptersResponse.error ?? defaultSettingsResponse.error;
const page = getPage(currentPageIndex, pages);
const primaryPageBackground = getReaderBackgroundColor(
backgroundColor,
pageBackgroundColors[page.primary.index]?.color,
isContinuousReadingMode(readingMode.value),
useAutoBackgroundColorContinuousMode,
theme,
invertColors,
applySepia,
applyGrayscale,
);
const secondaryPageBackground = getReaderBackgroundColor(
backgroundColor,
pageBackgroundColors[page.secondary?.index ?? page.primary.index]?.color,
isContinuousReadingMode(readingMode.value),
useAutoBackgroundColorContinuousMode,
theme,
invertColors,
applySepia,
applyGrayscale,
);
const direction = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection.value];
useEffect(() => {
getReaderStore().setManga(mangaResponse.data?.manga);
}, [mangaResponse.data?.manga]);
@@ -234,10 +274,11 @@ const BaseReader = ({
pr: safeAreaInset.right ? 'env(safe-area-inset-right)' : undefined,
pl: safeAreaInset.left ? 'env(safe-area-inset-left)' : undefined,
marginLeft: `${readerNavBarWidth}px`,
transition: (theme) =>
`width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`,
transition: `width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s, background 1.${theme.transitions.duration.shortest}s`,
overflow: 'auto',
backgroundColor: READER_BACKGROUND_TO_COLOR[backgroundColor],
background: isContinuousReadingMode(readingMode.value)
? primaryPageBackground
: `linear-gradient(to right, ${getOptionForDirection(primaryPageBackground, secondaryPageBackground, direction)} 0 50%, ${getOptionForDirection(secondaryPageBackground, primaryPageBackground, direction)} 50% 100%)`,
}}
>
<ReaderViewer ref={scrollElementRef} />

View File

@@ -144,6 +144,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefine
shouldShowPageNumber: undefined,
isStaticNav: undefined,
backgroundColor: undefined,
useAutoBackgroundColorContinuousMode: undefined,
hotkeys: undefined,
imagePreLoadAmount: undefined,
shouldUseAutoWebtoonMode: undefined,
@@ -180,6 +181,7 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
readingMode: ReadingMode.SINGLE_PAGE,
exitMode: ReaderExitMode.PREVIOUS,
backgroundColor: ReaderBackgroundColor.THEME,
useAutoBackgroundColorContinuousMode: false,
customFilter: {
brightness: {
value: CUSTOM_FILTER.brightness.default,
@@ -379,6 +381,8 @@ export const READER_BACKGROUND_TO_COLOR = {
[ReaderBackgroundColor.BLACK]: 'common.black',
[ReaderBackgroundColor.GRAY]: 'grey.200',
[ReaderBackgroundColor.WHITE]: 'common.white',
// Used as the fallback color
[ReaderBackgroundColor.AUTO]: 'background.default',
} as const satisfies Record<ReaderBackgroundColor, string>;
export const CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION: Record<

View File

@@ -14,7 +14,12 @@ import type {
ReaderPagerProps,
SafeAreaInset,
} from '@/features/reader/Reader.types.ts';
import { ProgressBarPosition, ReaderPageScaleMode, ReadingMode } from '@/features/reader/Reader.types.ts';
import {
ProgressBarPosition,
ReaderBackgroundColor,
ReaderPageScaleMode,
ReadingMode,
} from '@/features/reader/Reader.types.ts';
import type { MangaGenreInfo, MangaSourceNameInfo } from '@/features/manga/Manga.types.ts';
import { Mangas } from '@/features/manga/services/Mangas.ts';
import { ReaderPagedPager } from '@/features/reader/viewer/pager/components/ReaderPagedPager.tsx';
@@ -22,6 +27,10 @@ import { ReaderDoublePagedPager } from '@/features/reader/viewer/pager/component
import { ReaderVerticalPager } from '@/features/reader/viewer/pager/components/ReaderVerticalPager.tsx';
import { ReaderHorizontalPager } from '@/features/reader/viewer/pager/components/ReaderHorizontalPager.tsx';
import { ScrollDirection } from '@/base/Base.types.ts';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { getValueFromObject } from '@/lib/HelperFunctions.ts';
import type { Theme } from '@mui/material/styles';
import { Colors } from '@/lib/Colors.ts';
export const isOffsetDoubleSpreadPagesEditable = (readingMode: IReaderSettings['readingMode']): boolean =>
readingMode === ReadingMode.DOUBLE_PAGE;
@@ -117,3 +126,33 @@ export const getSafeAreaInsets = (
...(direction === ScrollDirection.Y && safeAreaInset.bottom ? ['env(safe-area-inset-bottom)'] : []),
...(direction === ScrollDirection.X && safeAreaInset.left ? ['env(safe-area-inset-left)'] : []),
];
export const getReaderBackgroundColor = (
backgroundColor: ReaderBackgroundColor,
pageBackgroundColor: string | undefined,
isContinuousReadingModeFlag: boolean,
useAutoBackgroundColorContinuousMode: boolean,
theme: Theme,
invertColors: boolean = false,
applySepia: boolean = false,
applyGrayscale: boolean = false,
): string => {
const applyFilters = (hex: string): string => {
const maybeGray = applyGrayscale ? Colors.grayscaleHex(hex) : hex;
const maybeSepia = applySepia ? Colors.sepiaHex(maybeGray) : maybeGray;
const maybeInverted = invertColors ? Colors.invertColorHex(maybeSepia) : maybeSepia;
return maybeInverted;
};
const isAuto = backgroundColor === ReaderBackgroundColor.AUTO;
const isAutoColorUsable = isAuto && !!pageBackgroundColor;
const isAutoColorAllowed = !isContinuousReadingModeFlag || useAutoBackgroundColorContinuousMode;
const useAutoColor = isAutoColorUsable && isAutoColorAllowed;
if (useAutoColor) {
return applyFilters(pageBackgroundColor);
}
return getValueFromObject(theme.palette, READER_BACKGROUND_TO_COLOR[backgroundColor]);
};

View File

@@ -33,7 +33,8 @@ export const ReaderGeneralSettings = ({
/>
<ReaderSettingBackgroundColor
backgroundColor={settings.backgroundColor}
updateSetting={(value) => updateSetting('backgroundColor', value)}
useAutoBackgroundColorContinuousMode={settings.useAutoBackgroundColorContinuousMode}
updateSetting={(...args) => updateSetting(...args)}
/>
<ReaderSettingProgressBarType
overlayMode={overlayMode}

View File

@@ -12,6 +12,8 @@ import type { ValueToDisplayData } from '@/base/Base.types.ts';
import { ButtonSelectInput } from '@/base/components/inputs/ButtonSelectInput.tsx';
import type { IReaderSettingsWithDefaultFlag } from '@/features/reader/Reader.types.ts';
import { ReaderBackgroundColor } from '@/features/reader/Reader.types.ts';
import type { ReaderService } from '@/features/reader/services/ReaderService.ts';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderBackgroundColor> = {
[ReaderBackgroundColor.THEME]: {
@@ -30,6 +32,10 @@ const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<ReaderBackgroundColor> = {
title: msg`White`,
icon: null,
},
[ReaderBackgroundColor.AUTO]: {
title: msg`Auto`,
icon: null,
},
};
const READER_BACKGROUND_COLOR_VALUES = Object.values(ReaderBackgroundColor).filter(
@@ -38,19 +44,29 @@ const READER_BACKGROUND_COLOR_VALUES = Object.values(ReaderBackgroundColor).filt
export const ReaderSettingBackgroundColor = ({
backgroundColor,
useAutoBackgroundColorContinuousMode,
updateSetting,
}: Pick<IReaderSettingsWithDefaultFlag, 'backgroundColor'> & {
updateSetting: (color: ReaderBackgroundColor) => void;
}: Pick<IReaderSettingsWithDefaultFlag, 'backgroundColor' | 'useAutoBackgroundColorContinuousMode'> & {
updateSetting: (...args: Parameters<typeof ReaderService.updateSetting>) => void;
}) => {
const { t } = useLingui();
return (
<>
<ButtonSelectInput
label={t`Background color`}
value={backgroundColor}
values={READER_BACKGROUND_COLOR_VALUES}
setValue={updateSetting}
setValue={(value) => updateSetting('backgroundColor', value)}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
{backgroundColor === ReaderBackgroundColor.AUTO && (
<CheckboxInput
label={t`Enable auto background color in continuous reading mode`}
checked={useAutoBackgroundColorContinuousMode}
onChange={(_, checked) => updateSetting('useAutoBackgroundColorContinuousMode', checked)}
/>
)}
</>
);
};

View File

@@ -25,6 +25,7 @@ export const READER_DEFAULT_PAGES_STATE: Omit<
| 'setPageUrls'
| 'setPageSpreadStates'
| 'setPageLoadStates'
| 'setPageBackgroundColor'
| 'setPages'
| 'setTransitionPageMode'
| 'setRetryFailedPagesKeyPrefix'
@@ -36,6 +37,7 @@ export const READER_DEFAULT_PAGES_STATE: Omit<
pageUrls: [],
pageSpreadStates: [{ url: '', isSpread: false }],
pageLoadStates: [{ url: '', loaded: false }],
pageBackgroundColors: [{ url: '', color: undefined }],
pages: [
{
name: '1',
@@ -114,6 +116,19 @@ export const createReaderPagesStoreSlice = <T extends ReaderPagesStoreSlice>(
undefined,
createActionName('setPageLoadStates'),
),
setPageBackgroundColor: (dominantColors) =>
set(
(draft) => {
if (typeof dominantColors === 'function') {
draft.pages.pageBackgroundColors = dominantColors(get().pages.pageBackgroundColors);
return;
}
draft.pages.pageBackgroundColors = dominantColors;
},
undefined,
createActionName('setPageBackgroundColors'),
),
setPages: (pages) =>
set(
(draft) => {

View File

@@ -14,6 +14,7 @@ import Box from '@mui/material/Box';
import { useLingui } from '@lingui/react/macro';
import type {
IReaderSettings,
ReaderPageBackgroundColor,
ReaderPagerProps,
ReaderPageSpreadState,
ReaderResumeMode,
@@ -124,6 +125,9 @@ const BaseReaderChapterViewer = ({
const [pagesToSpreadState, setPagesToSpreadState] = useState<ReaderPageSpreadState[]>(
READER_DEFAULT_PAGES_STATE.pageSpreadStates,
);
const [pageBackgroundColors, setPageBackgroundColors] = useState<ReaderPageBackgroundColor[]>(
READER_DEFAULT_PAGES_STATE.pageBackgroundColors,
);
const ref = useRef<HTMLDivElement>(null);
const isCurrentChapterRef = useRef(isCurrentChapter);
@@ -197,6 +201,13 @@ const BaseReaderChapterViewer = ({
setPageLoadStates(value);
},
(value) => {
if (isCurrentChapterRef.current) {
getReaderPagesStore().setPageBackgroundColor(value);
}
setPageBackgroundColors(value);
},
readingMode,
),
[actualPages, readingMode],
@@ -251,6 +262,7 @@ const BaseReaderChapterViewer = ({
actualPages,
pageLoadStates,
pagesToSpreadState,
pageBackgroundColors,
arePagesFetched,
setArePagesFetched,
(value) => updateState(value, noOp, getReaderChaptersStore().setReaderStateChapters),
@@ -265,6 +277,12 @@ const BaseReaderChapterViewer = ({
setPagesToSpreadState,
getReaderPagesStore().setPageSpreadStates.bind(getReaderPagesStore()),
),
(value) =>
updateState(
value,
setPageBackgroundColors,
getReaderPagesStore().setPageBackgroundColor.bind(getReaderPagesStore()),
),
(value) => updateState(value, noOp, getReaderPagesStore().setCurrentPageIndex.bind(getReaderPagesStore())),
(value) => {
if ((isInitialChapter && !arePagesFetched) || scrollIntoView) {

View File

@@ -14,7 +14,7 @@ import type { ComponentProps } from 'react';
import { memo, useMemo } from 'react';
import { useTheme } from '@mui/material/styles';
import { useLingui } from '@lingui/react/macro';
import type { IReaderSettings } from '@/features/reader/Reader.types.ts';
import type { ReaderBackgroundColor } from '@/features/reader/Reader.types.ts';
import { ReaderTransitionPageMode, ReadingMode } from '@/features/reader/Reader.types.ts';
import { isTransitionPageVisible } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import { useBackButton } from '@/base/hooks/useBackButton.ts';
@@ -28,7 +28,6 @@ import { AppRoutes } from '@/base/AppRoute.constants.ts';
import type { NavbarContextType } from '@/features/navigation-bar/NavigationBar.types.ts';
import { withPropsFrom } from '@/base/hoc/withPropsFrom.tsx';
import { getValueFromObject, noOp } from '@/lib/HelperFunctions.ts';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
import type { ChapterIdInfo, ChapterNameInfo, ChapterScanlatorInfo } from '@/features/chapter/Chapter.types.ts';
import {
useReaderChaptersStore,
@@ -37,6 +36,7 @@ import {
useReaderSettingsStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { READER_BACKGROUND_TO_COLOR } from '@/features/reader/settings/ReaderSettings.constants.tsx';
const ChapterInfo = ({
title,
@@ -47,12 +47,12 @@ const ChapterInfo = ({
title: string;
name?: ChapterNameInfo['name'];
scanlator?: ChapterScanlatorInfo['scanlator'];
backgroundColor: IReaderSettings['backgroundColor'];
backgroundColor: ReaderBackgroundColor;
}) => {
const theme = useTheme();
const contrastText = theme.palette.getContrastText(
getValueFromObject(theme.palette, READER_BACKGROUND_TO_COLOR[backgroundColor]),
getValueFromObject<string>(theme.palette, READER_BACKGROUND_TO_COLOR[backgroundColor]),
);
const disabledText = theme.alpha(contrastText, 0.5);
@@ -131,21 +131,22 @@ const BaseReaderTransitionPage = ({
sx={{
justifyContent: 'center',
alignItems: 'center',
backgroundColor: READER_BACKGROUND_TO_COLOR[backgroundColor],
...applyStyles(!isContinuousReadingMode(readingMode), {
width: '100%',
height: '100%',
width: `calc(100vw - ${scrollbar.ySize}px - ${readerNavBarWidth}px)`,
height: `calc(100vh - ${scrollbar.xSize}px)`,
}),
...applyStyles(isContinuousReadingMode(readingMode), {
position: 'sticky',
...applyStyles(isContinuousVerticalReadingMode(readingMode), {
left: 0,
maxWidth: `calc(100vw - ${scrollbar.ySize}px - ${readerNavBarWidth}px)`,
minHeight: `calc(100vh - ${scrollbar.xSize}px)`,
width: `calc(100vw - ${scrollbar.ySize}px - ${readerNavBarWidth}px)`,
height: `calc(100vh - ${scrollbar.xSize}px)`,
}),
...applyStyles(readingMode === ReadingMode.CONTINUOUS_HORIZONTAL, {
top: 0,
minWidth: `calc(100vw - ${scrollbar.ySize}px - ${readerNavBarWidth}px)`,
maxHeight: `calc(100vh - ${scrollbar.xSize}px)`,
width: `calc(100vw - ${scrollbar.ySize}px - ${readerNavBarWidth}px)`,
height: `calc(100vh - ${scrollbar.xSize}px)`,
}),
}),
}}

View File

@@ -9,7 +9,12 @@
import { useLayoutEffect, useRef } from 'react';
import { getInitialReaderPageIndex } from '@/features/reader/Reader.utils.ts';
import { createPagesData } from '@/features/reader/viewer/pager/ReaderPager.utils.tsx';
import type { ReaderPageSpreadState, ReaderResumeMode, ReaderStatePages } from '@/features/reader/Reader.types.ts';
import type {
ReaderPageBackgroundColor,
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStatePages,
} from '@/features/reader/Reader.types.ts';
import { ReaderTransitionPageMode } from '@/features/reader/Reader.types.ts';
import type { requestManager } from '@/lib/requests/RequestManager.ts';
import type { TChapterReader } from '@/features/chapter/Chapter.types.ts';
@@ -25,6 +30,7 @@ export const useReaderSetPagesState = (
pages: ReaderStatePages['pages'],
pageLoadStates: ReaderStatePages['pageLoadStates'],
pagesToSpreadState: ReaderPageSpreadState[],
pageBackgroundColors: ReaderPageBackgroundColor[],
arePagesFetched: boolean,
setArePagesFetched: (fetched: boolean) => void,
setReaderStateChapters: ReaderChaptersStoreSlice['chapters']['setReaderStateChapters'],
@@ -33,6 +39,7 @@ export const useReaderSetPagesState = (
setPageUrls: ReaderStatePages['setPageUrls'],
setPageLoadStates: ReaderStatePages['setPageLoadStates'],
setPagesToSpreadState: (state: ReaderPageSpreadState[]) => void,
setPageBackgroundColors: (state: ReaderPageBackgroundColor[]) => void,
setCurrentPageIndex: ReaderStatePages['setCurrentPageIndex'],
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
setTransitionPageMode: ReaderStatePages['setTransitionPageMode'],
@@ -62,10 +69,12 @@ export const useReaderSetPagesState = (
setPageUrls(newPages);
setPageLoadStates(newPageData.map(({ primary: { url } }) => ({ url, loaded: false })));
setPagesToSpreadState(newPageData.map(({ primary: { url } }) => ({ url, isSpread: false })));
setPageBackgroundColors(newPageData.map(({ primary: { url } }) => ({ url, color: undefined })));
} else {
setPages(pages);
setPageLoadStates(pageLoadStates);
setPagesToSpreadState(pagesToSpreadState);
setPageBackgroundColors(pageBackgroundColors);
}
setTotalPages(pagesPayload.pages.length);

View File

@@ -648,7 +648,5 @@ export const getScrollToXForReadingDirection = (
return getOptionForDirection(-element.scrollWidth, 0, themeDirectionForReadingDirection);
};
export const isPageOfOutdatedPageLoadStates = (
url: string,
pageLoadState: ReaderStatePages['pageLoadStates'][number] | undefined,
): boolean => pageLoadState === undefined || pageLoadState.url !== url;
export const isPageOfOutdatedPageLoadStates = (url: string, state: { url: string } | undefined): boolean =>
state === undefined || state.url !== url;

View File

@@ -499,6 +499,7 @@ msgstr "Author"
#: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx
#: src/features/reader/overlay/settings/ReaderSettingOverlayMode.tsx
#: src/features/reader/settings/general/components/ReaderSettingBackgroundColor.tsx
msgid "Auto"
msgstr "Auto"
@@ -1578,6 +1579,10 @@ msgstr "Electron"
msgid "Electron path"
msgstr "Electron path"
#: src/features/reader/settings/general/components/ReaderSettingBackgroundColor.tsx
msgid "Enable auto background color in continuous reading mode"
msgstr "Enable auto background color in continuous reading mode"
#: src/features/settings/screens/ServerSettings.tsx
msgid "Enable debug logs"
msgstr "Enable debug logs"

84
src/lib/Colors.ts Normal file
View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
type Rgb = {
r: number;
g: number;
b: number;
};
export class Colors {
static hexToRgb(hex: string): Rgb {
let tmpHex = hex.replace(/^#/, '');
const isShorthand = tmpHex.length === 3; // #abc
if (isShorthand) {
tmpHex = hex
.split('')
.map((c) => c + c)
.join('');
}
return {
r: parseInt(tmpHex.slice(0, 2), 16),
g: parseInt(tmpHex.slice(2, 4), 16),
b: parseInt(tmpHex.slice(4, 6), 16),
};
}
static rgbToHex({ r, g, b }: Rgb): string {
// oxlint-disable-next-line unicorn/consistent-function-scoping
const toHex = (value: number) =>
Math.round(Math.max(0, Math.min(255, value)))
.toString(16)
.padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
static grayScaleRgb({ r, g, b }: Rgb): Rgb {
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
return {
r: gray,
g: gray,
b: gray,
};
}
static grayscaleHex(hex: string): string {
return Colors.rgbToHex(Colors.grayScaleRgb(Colors.hexToRgb(hex)));
}
static sepiaRgb({ r, g, b }: Rgb, amount = 1): Rgb {
const sepia = {
r: r * 0.393 + g * 0.769 + b * 0.189,
g: r * 0.349 + g * 0.686 + b * 0.168,
b: r * 0.272 + g * 0.534 + b * 0.131,
};
return {
r: Math.min(255, r + (sepia.r - r) * amount),
g: Math.min(255, g + (sepia.g - g) * amount),
b: Math.min(255, b + (sepia.b - b) * amount),
};
}
static sepiaHex(hex: string): string {
return Colors.rgbToHex(Colors.sepiaRgb(Colors.hexToRgb(hex)));
}
static invertColorHex(hex: string): string {
const clean = hex.replace('#', '');
const r = 255 - parseInt(clean.substring(0, 2), 16);
const g = 255 - parseInt(clean.substring(2, 4), 16);
const b = 255 - parseInt(clean.substring(4, 6), 16);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
}
}