Files
suwayomi-material-you-webui/src/screens/Reader.tsx
schroda ab8cc918a7 Use correct fragment to read chapters from cache
The used fragment specified properties that were not in the cache, resulting in null being returned instead of the required chapter data
2024-07-09 23:50:51 +02:00

580 lines
21 KiB
TypeScript

/*
* 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 CircularProgress from '@mui/material/CircularProgress';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import { useTranslation } from 'react-i18next';
import { AllowedMetadataValueTypes, ChapterOffset, IReaderSettings, ReaderType } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import {
checkAndHandleMissingStoredReaderSettings,
getReaderSettingsFor,
useDefaultReaderSettings,
} from '@/lib/metadata/readerSettings.ts';
import { requestUpdateMangaMetadata } from '@/lib/metadata/metadata.ts';
import { HorizontalPager } from '@/components/reader/pager/HorizontalPager';
import { PageNumber } from '@/components/reader/PageNumber';
import { PagedPager } from '@/components/reader/pager/PagedPager';
import { DoublePagedPager } from '@/components/reader/pager/DoublePagedPager';
import { VerticalPager } from '@/components/reader/pager/VerticalPager';
import { ReaderNavBar } from '@/components/navbar/ReaderNavBar';
import { makeToast } from '@/components/util/Toast';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { useDebounce } from '@/util/useDebounce.ts';
import {
GetChaptersReaderQuery,
GetChaptersReaderQueryVariables,
GetMangaReaderQuery,
UpdateChapterPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { ChapterIdInfo, Chapters } from '@/lib/data/Chapters.ts';
import { EmptyViewAbsoluteCentered } from '@/components/util/EmptyViewAbsoluteCentered.tsx';
import { GET_CHAPTERS_READER } from '@/lib/graphql/queries/ChapterQuery.ts';
import { GET_MANGA_READER } from '@/lib/graphql/queries/MangaQuery.ts';
import { TMangaReader } from '@/lib/data/Mangas.ts';
import { CHAPTER_READER_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts';
type TChapter = GetChaptersReaderQuery['chapters']['nodes'][number];
const getChapterFromCache = (id: ChapterIdInfo['id']): TChapter | null =>
Chapters.getFromCache<TChapter>(id, CHAPTER_READER_FIELDS, 'CHAPTER_READER_FIELDS')!;
const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) {
case 'ContinuesVertical':
case 'Webtoon':
return VerticalPager;
break;
case 'SingleVertical':
case 'SingleRTL':
case 'SingleLTR':
return PagedPager;
break;
case 'DoubleVertical':
case 'DoubleRTL':
case 'DoubleLTR':
return DoublePagedPager;
break;
case 'ContinuesHorizontalLTR':
case 'ContinuesHorizontalRTL':
return HorizontalPager;
default:
return VerticalPager;
break;
}
};
const range = (n: number) => Array.from({ length: n }, (value, key) => key);
const fallbackChapter = {
pageCount: -1,
sourceOrder: -1,
chapterCount: 0,
lastPageRead: 0,
name: 'Loading...',
manga: { id: -1 },
} as unknown as TChapter;
export function Reader() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>();
const fallbackManga = useMemo(
() =>
({
id: +mangaId,
title: '',
thumbnailUrl: '',
genre: [],
inLibraryAt: 0,
lastReadAt: 0,
chapters: { totalCount: 0 },
trackRecords: { totalCount: 0 },
}) as unknown as TMangaReader,
[mangaId],
);
const {
data,
loading: isMangaLoading,
error: mangaError,
refetch: refetchManga,
} = requestManager.useGetManga<GetMangaReaderQuery>(GET_MANGA_READER, mangaId);
const loadedChapter = useRef<TChapter | null>(null);
const isChapterLoaded =
Number(mangaId) === loadedChapter.current?.mangaId &&
Number(chapterIndex) === loadedChapter.current?.sourceOrder &&
loadedChapter.current?.pageCount !== -1;
const manga = data?.manga ?? fallbackManga;
const {
data: chaptersData,
loading: isChapterLoading,
error: chapterError,
refetch: fetchChapter,
} = requestManager.useGetChapters<GetChaptersReaderQuery, GetChaptersReaderQueryVariables>(
GET_CHAPTERS_READER,
{
condition: { mangaId: Number(mangaId), sourceOrder: Number(chapterIndex) },
},
{
notifyOnNetworkStatusChange: true,
},
);
const chapterData = chaptersData?.chapters.nodes[0];
const arePagesUpdatedRef = useRef(false);
const {
settings: { downloadAheadLimit },
} = useMetadataServerSettings();
const isDownloadAheadEnabled = !!downloadAheadLimit;
const getLoadedChapter = () => {
const isAChapterLoaded = loadedChapter.current;
const isSameAsLoadedChapter = isAChapterLoaded && isChapterLoaded;
if (isSameAsLoadedChapter) {
const didPageCountChange = chapterData && loadedChapter.current?.pageCount !== chapterData.pageCount;
return didPageCountChange ? chapterData! : loadedChapter.current;
}
arePagesUpdatedRef.current = false;
if (chapterData) {
return chapterData;
}
return null;
};
loadedChapter.current = getLoadedChapter();
const chapter = loadedChapter.current ?? fallbackChapter;
const initialChapterRef = useRef<TChapter>(fallbackChapter);
if (initialChapterRef.current === fallbackChapter) {
initialChapterRef.current = chapter;
}
if (chapter.mangaId !== initialChapterRef.current?.mangaId) {
initialChapterRef.current = fallbackChapter;
}
const [fetchPages, { loading: arePagesLoading, error: pagesError }] = requestManager.useGetChapterPagesFetch(
chapter.id,
);
const doFetchPages = () => {
const shouldFetchPages = !isChapterLoading && !chapter.isDownloaded;
if (shouldFetchPages) {
fetchPages()
.then(() => {
arePagesUpdatedRef.current = true;
})
.catch(defaultPromiseErrorHandler('Reader::fetchPages'));
} else {
arePagesUpdatedRef.current = true;
}
};
useEffect(() => {
doFetchPages();
}, [chapter.id]);
const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false);
const [curPage, setCurPage] = useState<number>(0);
const isLastPage = curPage === chapter.pageCount - 1;
const curPageDebounced = useDebounce(curPage, isLastPage ? 0 : 1000);
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
const { setOverride, setTitle } = useContext(NavBarContext);
const [retrievingNextChapter, setRetrievingNextChapter] = useState(false);
const {
data: mangaChaptersData,
loading: areChaptersLoading,
error: chaptersError,
refetch: refetchChapters,
} = requestManager.useGetMangaChapters<GetChaptersReaderQuery, GetChaptersReaderQueryVariables>(
GET_CHAPTERS_READER,
mangaId,
{ nextFetchPolicy: 'standby' },
);
const mangaChapters = mangaChaptersData?.chapters.nodes;
const isLoading =
isMangaLoading ||
isChapterLoading ||
areChaptersLoading ||
arePagesLoading ||
(!arePagesUpdatedRef.current && !chapterError && !pagesError);
const error = mangaError ?? chapterError ?? chaptersError ?? pagesError;
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings));
const { settings: metadataSettings } = useMetadataServerSettings();
const uniqueChapters = useMemo(
() =>
settings.skipDupChapters
? Chapters.removeDuplicates(initialChapterRef.current, mangaChapters ?? [])
: mangaChapters ?? [],
[initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const prevChapters = useMemo(
() =>
Chapters.getNextChapters(chapter, mangaChapters ?? [], {
offset: ChapterOffset.PREV,
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const nextChapters = useMemo(
() =>
Chapters.getNextChapters(chapter, mangaChapters ?? [], {
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const prevChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
offset: ChapterOffset.PREV,
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const nextChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
skipDupe: settings.skipDupChapters,
skipDupeChapter: initialChapterRef.current,
}),
[chapter, initialChapterRef.current, mangaChapters, settings.skipDupChapters],
);
const updateChapter = (patch: UpdateChapterPatchInput) => {
if (chapter === fallbackChapter) {
return;
}
const getChapterIdsToDelete = () => {
const isAutoDeletionEnabled = !!patch.isRead && !!metadataSettings.deleteChaptersWhileReading;
if (!isAutoDeletionEnabled || !mangaChapters) {
return [];
}
const chapterToDelete = [chapter, ...prevChapters][metadataSettings.deleteChaptersWhileReading - 1];
if (!chapterToDelete) {
return [];
}
// chapter has to exist in the cache since the reader fetches all chapters of the manga
const chapterToDeleteUpToDateData = getChapterFromCache(chapterToDelete.id)!;
const shouldDeleteChapter =
chapterToDeleteUpToDateData.isRead &&
Chapters.isDeletable(chapterToDeleteUpToDateData, metadataSettings.deleteChaptersWithBookmark);
if (!shouldDeleteChapter) {
return [];
}
if (!settings.skipDupChapters) {
return Chapters.getIds([chapterToDelete]);
}
return Chapters.getIds(Chapters.addDuplicates([chapterToDelete], mangaChapters));
};
const downloadAhead = () => {
const currentChapter = getChapterFromCache(chapter.id);
const inDownloadRange = (patch.lastPageRead ?? 0) / chapter.pageCount > 0.25;
const shouldCheckDownloadAhead =
isDownloadAheadEnabled && manga.inLibrary && !!currentChapter?.isDownloaded && inDownloadRange;
if (shouldCheckDownloadAhead) {
const nextChapterUpToDate = nextChapter ? getChapterFromCache(nextChapter.id) : null;
if (!nextChapterUpToDate?.isDownloaded) {
return;
}
const nextChaptersUpToDate = Chapters.getNonRead(nextChapters).map(
// the chapters have to be in the cache since the reader fetches the whole chapter list of the manga
(mangaChapter) => getChapterFromCache(mangaChapter.id)!,
);
const chapterIdsToDownload = nextChaptersUpToDate
// "settingsData" can't be undefined since this would not get executed otherwise
.slice(-downloadAheadLimit)
.filter((mangaChapter) => !mangaChapter.isDownloaded)
.map((mangaChapter) => mangaChapter.id)
.filter((id) => !Chapters.isDownloading(id));
if (!chapterIdsToDownload.length) {
return;
}
Chapters.download(chapterIdsToDownload).catch(
defaultPromiseErrorHandler('Reader::updateChapter: shouldDownloadAhead'),
);
}
};
downloadAhead();
const chapterIds = settings.skipDupChapters
? Chapters.getIds(Chapters.addDuplicates([chapter], mangaChapters ?? [chapter]))
: [chapter.id];
requestManager
.updateChapters(
chapterIds,
{
...patch,
chapterIdsToDelete: getChapterIdsToDelete(),
trackProgressMangaId:
metadataSettings.updateProgressAfterReading && patch.isRead && manga.trackRecords.totalCount
? manga.id
: undefined,
},
{ errorPolicy: 'all' },
)
.response.catch(defaultPromiseErrorHandler('Reader::updateChapter'));
};
const setSettingValue = (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist: boolean = true) => {
setSettings({ ...settings, [key]: value });
if (persist) {
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() =>
makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
);
}
};
const openNextChapter = useCallback(
(offset: ChapterOffset) => {
const isOpenNextChapter = offset === ChapterOffset.NEXT;
const chapterToOpen = isOpenNextChapter ? nextChapter : prevChapter;
if (!chapterToOpen) {
makeToast(
t(
isOpenNextChapter
? 'reader.error.label.next_chapter_does_not_exist'
: 'reader.error.label.prev_chapter_does_not_exist',
),
'error',
);
return;
}
setRetrievingNextChapter(true);
setCurPage(0);
navigate(`/manga/${manga.id}/chapter/${chapterToOpen.sourceOrder}`, {
replace: true,
state: location.state,
});
setRetrievingNextChapter(false);
},
[manga.id, prevChapter?.id, nextChapter?.id],
);
useEffect(() => {
if (isLoading || !chapter) {
setCurPage(0);
return;
}
setWasLastPageReadSet(true);
if (chapter.lastPageRead === chapter.pageCount - 1) {
// last page, also probably read = true, we will load the first page.
setCurPage(0);
} else setCurPage(chapter.lastPageRead);
}, [chapter, isLoading]);
useEffect(() => {
if (!manga?.title || chapter.name === t('global.label.loading')) {
setTitle(t('reader.title'));
} else {
setTitle(`${manga.title}: ${chapter.name}`);
}
}, [t, manga, chapter]);
useEffect(() => {
if (!areDefaultSettingsLoading && !isMangaLoading) {
checkAndHandleMissingStoredReaderSettings(manga, 'manga', defaultSettings).catch(
defaultPromiseErrorHandler('Reader::checkAndHandleMissingStoredReaderSettings'),
);
setSettings(getReaderSettingsFor(manga, defaultSettings));
}
}, [areDefaultSettingsLoading, isMangaLoading]);
useEffect(() => {
// set the custom navbar
setOverride({
status: true,
value: (
<ReaderNavBar
settings={settings}
setSettingValue={setSettingValue}
manga={manga}
chapter={chapter}
chapters={uniqueChapters}
curPage={curPage}
scrollToPage={setPageToScrollTo}
openNextChapter={openNextChapter}
retrievingNextChapter={retrievingNextChapter}
/>
),
});
// clean up for when we leave the reader
return () => setOverride({ status: false, value: <div /> });
}, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter, openNextChapter, uniqueChapters]);
useEffect(() => {
if (!wasLastPageReadSet) {
return;
}
if (chapter === fallbackChapter) {
return;
}
const chapterUpToDate = getChapterFromCache(chapter.id);
if (curPageDebounced === chapterUpToDate?.lastPageRead) {
return;
}
// do not mutate the chapter, this will cause the page to jump around due to always scrolling to the last read page
const updateLastPageRead = curPageDebounced !== -1;
const updateIsRead = curPageDebounced === chapter.pageCount - 1;
const shouldUpdateChapter = updateLastPageRead || updateIsRead;
if (!shouldUpdateChapter) {
return;
}
updateChapter({
lastPageRead: updateLastPageRead ? curPageDebounced : undefined,
isRead: updateIsRead ? true : undefined,
});
}, [curPageDebounced, isDownloadAheadEnabled]);
const loadNextChapter = useCallback(() => {
updateChapter({
lastPageRead: chapter.pageCount - 1,
isRead: true,
});
openNextChapter(ChapterOffset.NEXT);
}, [chapter.pageCount, openNextChapter, chapter, manga]);
const loadPrevChapter = useCallback(() => {
openNextChapter(ChapterOffset.PREV);
}, [openNextChapter]);
if (isLoading) {
return (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'grid',
placeItems: 'center',
}}
>
<CircularProgress thickness={5} />
</Box>
);
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => {
if (mangaError) {
refetchManga().catch(defaultPromiseErrorHandler('Reader::refetchManga'));
}
if (chapterError) {
fetchChapter().catch(defaultPromiseErrorHandler('Reader::refetchChapter'));
}
if (chaptersError) {
refetchChapters().catch(defaultPromiseErrorHandler('Reader::refetchChapters'));
}
if (pagesError) {
doFetchPages();
}
}}
/>
);
}
if (!chapter.pageCount) {
return <EmptyViewAbsoluteCentered message={t('reader.error.label.no_pages_found')} retry={doFetchPages} />;
}
const pages = range(chapter.pageCount).map((index) => ({
index,
src: requestManager.getChapterPageUrl(mangaId, chapterIndex, index),
}));
const ReaderComponent = getReaderComponent(settings.readerType);
// last page, also probably read = true, we will load the first page.
const initialPage = pageToScrollTo ?? (chapter.lastPageRead === chapter.pageCount - 1 ? 0 : chapter.lastPageRead);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
minWidth: settings.staticNav
? 'calc((100vw - (100vw - 100%)) - 300px)'
: 'calc(100vw - (100vw - 100%))', // 100vw = width excluding scrollbar; 100% = width including scrollbar
minHeight: '100vh',
marginLeft: settings.staticNav ? '300px' : 'unset',
}}
>
<PageNumber settings={settings} curPage={curPage} pageCount={chapter.pageCount} />
<Box sx={{ alignSelf: 'stretch' }}>
<ReaderComponent
key={chapter.id}
pages={pages}
pageCount={chapter.pageCount}
setCurPage={setCurPage}
initialPage={initialPage}
curPage={curPage}
settings={settings}
manga={manga}
chapter={chapter}
nextChapter={loadNextChapter}
prevChapter={loadPrevChapter}
/>
</Box>
</Box>
);
}