From da0004de4a57de630c626014e25f4879ae78502f Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:24:55 +0100 Subject: [PATCH] Base reader component --- src/App.tsx | 10 +- .../constants/ReaderSettings.constants.tsx | 7 + src/modules/reader/screens/Reader.tsx | 340 ++++++++++++++++++ 3 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 src/modules/reader/screens/Reader.tsx diff --git a/src/App.tsx b/src/App.tsx index 25818d61..f15afa74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { ServerUpdateChecker } from '@/modules/app-updates/components/ServerUpda import { lazyLoadFallback } from '@/modules/core/utils/LazyLoad.tsx'; import { ErrorBoundary } from '@/modules/core/components/ErrorBoundary.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; +import { Reader } from '@/modules/reader/screens/Reader.tsx'; const { Browse } = loadable(() => import('@/modules/browse/screens/Browse.tsx'), lazyLoadFallback); const { DownloadQueue } = loadable(() => import('@/modules/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback); @@ -163,8 +164,7 @@ const MainApp = () => { const ReaderApp = () => ( - - + } /> ); @@ -180,8 +180,10 @@ export const App: React.FC = () => ( - - + + } /> + } /> + ); diff --git a/src/modules/reader/constants/ReaderSettings.constants.tsx b/src/modules/reader/constants/ReaderSettings.constants.tsx index bb558c28..042398d2 100644 --- a/src/modules/reader/constants/ReaderSettings.constants.tsx +++ b/src/modules/reader/constants/ReaderSettings.constants.tsx @@ -256,3 +256,10 @@ export enum ReaderScrollAmount { export const READER_HOTKEYS = Object.values(ReaderHotkey).filter( (hotkey) => typeof hotkey === 'number', ) as ReaderHotkey[]; + +export const READER_BACKGROUND_TO_COLOR: Record = { + [ReaderBackgroundColor.THEME]: 'background.default', + [ReaderBackgroundColor.BLACK]: 'black', + [ReaderBackgroundColor.GRAY]: 'gray', + [ReaderBackgroundColor.WHITE]: 'white', +}; diff --git a/src/modules/reader/screens/Reader.tsx b/src/modules/reader/screens/Reader.tsx new file mode 100644 index 00000000..30d2847b --- /dev/null +++ b/src/modules/reader/screens/Reader.tsx @@ -0,0 +1,340 @@ +/* + * 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/. + */ + +import Box from '@mui/material/Box'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + getReaderSettings, + getReaderSettingsFor, + useDefaultReaderSettings, +} from '@/modules/reader/services/ReaderSettingsMetadata.ts'; +import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; +import { ReaderOverlay } from '@/modules/reader/components/overlay/ReaderOverlay.tsx'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { GetChaptersReaderQuery, GetMangaReaderQuery } from '@/lib/graphql/generated/graphql.ts'; +import { GET_MANGA_READER } from '@/lib/graphql/queries/MangaQuery.ts'; +import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; +import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; +import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; +import { GET_CHAPTERS_READER } from '@/lib/graphql/queries/ChapterQuery.ts'; +import { Chapters } from '@/modules/chapter/services/Chapters.ts'; +import { DirectionOffset } from '@/Base.types.ts'; +import { TapZoneLayout } from '@/modules/reader/components/TapZoneLayout.tsx'; +import { useReaderOverlayContext } from '@/modules/reader/contexts/ReaderOverlayContext.tsx'; +import { ReaderRGBAFilter } from '@/modules/reader/components/ReaderRGBAFilter.tsx'; +import { useReaderStateSettingsContext } from '@/modules/reader/contexts/state/ReaderStateSettingsContext.tsx'; +import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx'; +import { ReaderViewer } from '@/modules/reader/components/viewer/ReaderViewer.tsx'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { READER_BACKGROUND_TO_COLOR } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { createPageData, createPagesData } from '@/modules/reader/utils/ReaderPager.utils.tsx'; +import { getValidReaderProfile } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; +import { ReaderHotkeys } from '@/modules/reader/components/ReaderHotkeys.tsx'; +import { ReaderResumeMode, ReaderTransitionPageMode } from '@/modules/reader/types/Reader.types.ts'; +import { getInitialReaderPageIndex } from '@/modules/reader/utils/Reader.utils.ts'; + +export const Reader = () => { + const { t } = useTranslation(); + const { setTitle, setOverride, readerNavBarWidth } = useNavBarContext(); + const { isVisible: isOverlayVisible, setIsVisible: setIsOverlayVisible } = useReaderOverlayContext(); + const { manga, setManga } = useReaderStateMangaContext(); + const { + settings: { shouldSkipDupChapters }, + setSettings, + } = useReaderStateSettingsContext(); + const { initialChapter, currentChapter, chapters, setReaderStateChapters } = useReaderStateChaptersContext(); + const { + setTotalPages, + setCurrentPageIndex, + setPageToScrollToIndex, + setPages, + setPageUrls, + setPageLoadStates, + setTransitionPageMode, + } = userReaderStatePagesContext(); + const { resumeMode } = useLocation<{ + resumeMode: ReaderResumeMode; + }>().state ?? { resumeMode: ReaderResumeMode.START }; + + const scrollElementRef = useRef(null); + + const { backgroundColor } = ReaderService.useSettings(); + + const { chapterIndex: paramChapterIndex, mangaId: paramMangaId } = useParams<{ + chapterIndex: string; + mangaId: string; + }>(); + const chapterIndex = Number(paramChapterIndex); + const mangaId = Number(paramMangaId); + + const mangaResponse = requestManager.useGetManga(GET_MANGA_READER, mangaId); + const chaptersResponse = requestManager.useGetMangaChapters(GET_CHAPTERS_READER, mangaId); + const [arePagesFetched, setArePagesFetched] = useState(false); + const [fetchPages, pagesResponse] = requestManager.useGetChapterPagesFetch(-1); + + const doFetchPages = useCallback(() => { + if (!currentChapter) { + return; + } + + setArePagesFetched(false); + + fetchPages({ variables: { input: { chapterId: currentChapter.id } } }).catch( + defaultPromiseErrorHandler('Reader::fetchPages'), + ); + }, [fetchPages, currentChapter?.id]); + + const { + metadata: defaultSettingsMetadata, + settings: defaultSettings, + request: defaultSettingsResponse, + } = useDefaultReaderSettings(); + + const doesChapterExist = + !chaptersResponse.loading && + !chaptersResponse.error && + chapterIndex >= 0 && + chapterIndex <= chapters.length - 1; + + const isLoading = + mangaResponse.loading || + chaptersResponse.loading || + defaultSettingsResponse.loading || + pagesResponse.loading || + (doesChapterExist && !arePagesFetched && !chaptersResponse.error && !pagesResponse.error); + const error = mangaResponse.error ?? chaptersResponse.error ?? defaultSettingsResponse.error ?? pagesResponse.error; + + useLayoutEffect(() => { + if (!manga || !currentChapter) { + setTitle(t('reader.title', { mangaId, chapterIndex })); + return; + } + + setTitle(`${manga.title}: ${currentChapter.name}`); + }, [t, mangaId, chapterIndex, manga, currentChapter]); + + useEffect(() => { + doFetchPages(); + return () => setArePagesFetched(false); + }, [currentChapter?.id]); + + useEffect(() => { + setManga(mangaResponse.data?.manga); + }, [mangaResponse.data?.manga]); + + // set pages state + useEffect(() => { + const pagesPayload = pagesResponse.data?.fetchChapterPages; + if (pagesPayload) { + const { pages } = pagesPayload; + + const initialReaderPageIndex = getInitialReaderPageIndex( + resumeMode, + currentChapter?.lastPageRead ?? 0, + pages.length - 1, + ); + + setArePagesFetched(true); + setTotalPages(pagesPayload.chapter.pageCount); + setPages(createPagesData(pages)); + setPageUrls(pages); + setPageLoadStates(pages.map(() => false)); + setCurrentPageIndex(initialReaderPageIndex); + setPageToScrollToIndex(initialReaderPageIndex); + } else { + setCurrentPageIndex(0); + setPageToScrollToIndex(0); + setTotalPages(0); + setPages([createPageData('', 0)]); + setPageUrls([]); + setPageLoadStates([]); + } + + setTransitionPageMode(ReaderTransitionPageMode.NONE); + }, [pagesResponse.data?.fetchChapterPages?.pages]); + + // set settings state + useEffect(() => { + if (!mangaResponse.data?.manga || defaultSettingsResponse.loading || defaultSettingsResponse.error) { + return; + } + + const settingsWithDefaultProfileFallback = getReaderSettingsFor( + mangaResponse.data?.manga ?? { id: -1 }, + defaultSettings, + ); + + const profile = getValidReaderProfile( + settingsWithDefaultProfileFallback.defaultProfile.isDefault + ? settingsWithDefaultProfileFallback.readingModesDefaultProfile[ + settingsWithDefaultProfileFallback.readingMode.value + ] + : settingsWithDefaultProfileFallback.defaultProfile.value, + settingsWithDefaultProfileFallback.profiles, + ); + + const profileSettings = getReaderSettings( + 'global', + { meta: defaultSettingsMetadata! }, + defaultSettings, + undefined, + profile, + ); + + setSettings(getReaderSettingsFor(mangaResponse.data!.manga, profileSettings)); + }, [mangaResponse.data?.manga, defaultSettings]); + + // set chapters state + useEffect(() => { + const newMangaChapters = chaptersResponse.data?.chapters.nodes; + const newCurrentChapter = newMangaChapters + ? (newMangaChapters[newMangaChapters.length - chapterIndex] ?? null) + : undefined; + const newInitialChapter = initialChapter ?? newCurrentChapter; + + setReaderStateChapters({ + mangaChapters: newMangaChapters ?? [], + chapters: + newInitialChapter && newMangaChapters + ? Chapters.removeDuplicates(newInitialChapter, newMangaChapters) + : [], + initialChapter: newInitialChapter, + currentChapter: newCurrentChapter, + nextChapter: + newMangaChapters && + newCurrentChapter && + Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { + offset: DirectionOffset.NEXT, + skipDupe: shouldSkipDupChapters, + skipDupeChapter: newInitialChapter, + }), + previousChapter: + newMangaChapters && + newCurrentChapter && + Chapters.getNextChapter(newCurrentChapter, newMangaChapters, { + offset: DirectionOffset.PREVIOUS, + skipDupe: shouldSkipDupChapters, + skipDupeChapter: newInitialChapter, + }), + }); + }, [chaptersResponse.data?.chapters.nodes, chapterIndex, shouldSkipDupChapters]); + + useLayoutEffect(() => { + setOverride({ + status: true, + value: ( + + + {!scrollElementRef.current && ( + setIsOverlayVisible(!isOverlayVisible)} + sx={{ + width: '100vw', + height: '100vh', + background: 'transparent', + }} + /> + )} + + ), + }); + + return () => setOverride({ status: false, value: null }); + }, [isOverlayVisible, setIsOverlayVisible, scrollElementRef.current]); + + const [isInitialLoad, setIsInitialLoad] = useState(true); + useEffect(() => { + if (isInitialLoad) { + setIsOverlayVisible(isLoading || !!error); + setIsInitialLoad(false); + } + }, [isLoading, error]); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + { + if (mangaResponse.error) { + mangaResponse.refetch().catch(defaultPromiseErrorHandler('Reader::refetchManga')); + } + + if (defaultSettingsResponse.error) { + defaultSettingsResponse + .refetch() + .catch(defaultPromiseErrorHandler('Reader::refetchDefaultSettings')); + } + + if (chaptersResponse.error) { + chaptersResponse.refetch().catch(defaultPromiseErrorHandler('Reader::refetchChapters')); + } + + if (pagesResponse.error) { + doFetchPages(); + } + }} + /> + ); + } + + if (currentChapter === null) { + return ; + } + + if (currentChapter && !currentChapter?.pageCount) { + return ; + } + + if (!manga || !currentChapter) { + return null; + } + + return ( + <> + + + `width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`, + overflow: 'auto', + backgroundColor: READER_BACKGROUND_TO_COLOR[backgroundColor], + }} + > + + + + + + ); +};