Base reader component
This commit is contained in:
@@ -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, string> = {
|
||||
[ReaderBackgroundColor.THEME]: 'background.default',
|
||||
[ReaderBackgroundColor.BLACK]: 'black',
|
||||
[ReaderBackgroundColor.GRAY]: 'gray',
|
||||
[ReaderBackgroundColor.WHITE]: 'white',
|
||||
};
|
||||
|
||||
340
src/modules/reader/screens/Reader.tsx
Normal file
340
src/modules/reader/screens/Reader.tsx
Normal file
@@ -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<HTMLDivElement | null>(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<GetMangaReaderQuery>(GET_MANGA_READER, mangaId);
|
||||
const chaptersResponse = requestManager.useGetMangaChapters<GetChaptersReaderQuery>(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: (
|
||||
<Box sx={{ position: 'absolute' }}>
|
||||
<ReaderOverlay isVisible={isOverlayVisible} />
|
||||
{!scrollElementRef.current && (
|
||||
<Box
|
||||
onClick={() => setIsOverlayVisible(!isOverlayVisible)}
|
||||
sx={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<LoadingPlaceholder />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t('global.error.label.failed_to_load_data')}
|
||||
messageExtra={error.message}
|
||||
retry={() => {
|
||||
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 <EmptyViewAbsoluteCentered message={t('reader.error.label.chapter_not_found')} />;
|
||||
}
|
||||
|
||||
if (currentChapter && !currentChapter?.pageCount) {
|
||||
return <EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />;
|
||||
}
|
||||
|
||||
if (!manga || !currentChapter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReaderHotkeys scrollElementRef={scrollElementRef} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: `calc(100vw - ${readerNavBarWidth}px)`,
|
||||
height: `100vh`,
|
||||
marginLeft: `${readerNavBarWidth}px`,
|
||||
transition: (theme) =>
|
||||
`width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`,
|
||||
overflow: 'auto',
|
||||
backgroundColor: READER_BACKGROUND_TO_COLOR[backgroundColor],
|
||||
}}
|
||||
>
|
||||
<ReaderViewer ref={scrollElementRef} />
|
||||
<TapZoneLayout />
|
||||
<ReaderRGBAFilter />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user