From 3562f5f6478547d74e67344188925e78b5f8bd47 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:34:51 +0200 Subject: [PATCH] Add auto reader background color --- CHANGELOG.md | 1 + package.json | 1 + pnpm-lock.yaml | 14 ++ src/features/metadata/Metadata.constants.ts | 3 + src/features/reader/Reader.types.ts | 8 + src/features/reader/Reader.utils.ts | 153 +++++++++++++++++- src/features/reader/screens/Reader.tsx | 63 ++++++-- .../settings/ReaderSettings.constants.tsx | 4 + .../reader/settings/ReaderSettings.utils.tsx | 41 ++++- .../general/ReaderGeneralSettings.tsx | 3 +- .../ReaderSettingBackgroundColor.tsx | 34 ++-- .../reader/stores/ReaderPagesStore.ts | 15 ++ .../reader/viewer/ReaderChapterViewer.tsx | 18 +++ .../components/ReaderTransitionPage.tsx | 21 +-- .../viewer/hooks/useReaderSetPagesState.ts | 11 +- .../reader/viewer/pager/ReaderPager.utils.tsx | 6 +- src/i18n/locales/en.po | 5 + src/lib/Colors.ts | 84 ++++++++++ 18 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 src/lib/Colors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf726c3..a85e16ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 694550b5..d08b52b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f0af0c0..6dee41a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/features/metadata/Metadata.constants.ts b/src/features/metadata/Metadata.constants.ts index a00098ff..b25cf2da 100644 --- a/src/features/metadata/Metadata.constants.ts +++ b/src/features/metadata/Metadata.constants.ts @@ -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), diff --git a/src/features/reader/Reader.types.ts b/src/features/reader/Reader.types.ts index 0e97c998..31b81755 100644 --- a/src/features/reader/Reader.types.ts +++ b/src/features/reader/Reader.types.ts @@ -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; 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 }; diff --git a/src/features/reader/Reader.utils.ts b/src/features/reader/Reader.utils.ts index dc1f3ac2..0b0a090b 100644 --- a/src/features/reader/Reader.utils.ts +++ b/src/features/reader/Reader.utils.ts @@ -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 => { + 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>, 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 = () => { + 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; - } + } + }; + img.crossOrigin = 'anonymous'; + img.src = url; setPageLoadStates((statePageLoadStates) => { const pageLoadState = statePageLoadStates[index]; diff --git a/src/features/reader/screens/Reader.tsx b/src/features/reader/screens/Reader.tsx index 1ef09ad5..89101965 100644 --- a/src/features/reader/screens/Reader.tsx +++ b/src/features/reader/screens/Reader.tsx @@ -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) => { 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(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%)`, }} > diff --git a/src/features/reader/settings/ReaderSettings.constants.tsx b/src/features/reader/settings/ReaderSettings.constants.tsx index ed376d99..e67ac9df 100644 --- a/src/features/reader/settings/ReaderSettings.constants.tsx +++ b/src/features/reader/settings/ReaderSettings.constants.tsx @@ -144,6 +144,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record; export const CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION: Record< diff --git a/src/features/reader/settings/ReaderSettings.utils.tsx b/src/features/reader/settings/ReaderSettings.utils.tsx index 122bc51b..efef8e98 100644 --- a/src/features/reader/settings/ReaderSettings.utils.tsx +++ b/src/features/reader/settings/ReaderSettings.utils.tsx @@ -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]); +}; diff --git a/src/features/reader/settings/general/ReaderGeneralSettings.tsx b/src/features/reader/settings/general/ReaderGeneralSettings.tsx index 15f25289..1f073469 100644 --- a/src/features/reader/settings/general/ReaderGeneralSettings.tsx +++ b/src/features/reader/settings/general/ReaderGeneralSettings.tsx @@ -33,7 +33,8 @@ export const ReaderGeneralSettings = ({ /> updateSetting('backgroundColor', value)} + useAutoBackgroundColorContinuousMode={settings.useAutoBackgroundColorContinuousMode} + updateSetting={(...args) => updateSetting(...args)} /> = { [ReaderBackgroundColor.THEME]: { @@ -30,6 +32,10 @@ const VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { 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 & { - updateSetting: (color: ReaderBackgroundColor) => void; +}: Pick & { + updateSetting: (...args: Parameters) => void; }) => { const { t } = useLingui(); return ( - + <> + updateSetting('backgroundColor', value)} + valueToDisplayData={VALUE_TO_DISPLAY_DATA} + /> + {backgroundColor === ReaderBackgroundColor.AUTO && ( + updateSetting('useAutoBackgroundColorContinuousMode', checked)} + /> + )} + ); }; diff --git a/src/features/reader/stores/ReaderPagesStore.ts b/src/features/reader/stores/ReaderPagesStore.ts index 771a22a4..89c8a89d 100644 --- a/src/features/reader/stores/ReaderPagesStore.ts +++ b/src/features/reader/stores/ReaderPagesStore.ts @@ -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 = ( 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) => { diff --git a/src/features/reader/viewer/ReaderChapterViewer.tsx b/src/features/reader/viewer/ReaderChapterViewer.tsx index 5ef120e5..07a77802 100644 --- a/src/features/reader/viewer/ReaderChapterViewer.tsx +++ b/src/features/reader/viewer/ReaderChapterViewer.tsx @@ -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( READER_DEFAULT_PAGES_STATE.pageSpreadStates, ); + const [pageBackgroundColors, setPageBackgroundColors] = useState( + READER_DEFAULT_PAGES_STATE.pageBackgroundColors, + ); const ref = useRef(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) { diff --git a/src/features/reader/viewer/components/ReaderTransitionPage.tsx b/src/features/reader/viewer/components/ReaderTransitionPage.tsx index 51cd8ca9..b216e0e0 100644 --- a/src/features/reader/viewer/components/ReaderTransitionPage.tsx +++ b/src/features/reader/viewer/components/ReaderTransitionPage.tsx @@ -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(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)`, }), }), }} diff --git a/src/features/reader/viewer/hooks/useReaderSetPagesState.ts b/src/features/reader/viewer/hooks/useReaderSetPagesState.ts index dd46aed9..c86a7e2a 100644 --- a/src/features/reader/viewer/hooks/useReaderSetPagesState.ts +++ b/src/features/reader/viewer/hooks/useReaderSetPagesState.ts @@ -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); diff --git a/src/features/reader/viewer/pager/ReaderPager.utils.tsx b/src/features/reader/viewer/pager/ReaderPager.utils.tsx index 5af2834c..8c7d4da6 100644 --- a/src/features/reader/viewer/pager/ReaderPager.utils.tsx +++ b/src/features/reader/viewer/pager/ReaderPager.utils.tsx @@ -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; diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index d92fd781..99772518 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -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" diff --git a/src/lib/Colors.ts b/src/lib/Colors.ts new file mode 100644 index 00000000..45d10244 --- /dev/null +++ b/src/lib/Colors.ts @@ -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('')}`; + } +}