/* * 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'; import { useTranslation } from 'react-i18next'; import { ChapterOffset, IReaderSettings, ReaderType, TChapter, TManga, TranslationKey } from '@/typings'; import requestManager from '@/lib/requests/RequestManager.ts'; import { checkAndHandleMissingStoredReaderSettings, getReaderSettingsFor, useDefaultReaderSettings, } from '@/util/readerSettings'; import { requestUpdateMangaMetadata } from '@/util/metadata'; 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 NavbarContext from '@/components/context/NavbarContext'; import makeToast from '@/components/util/Toast'; const isDupChapter = async (chapterIndex: number, currentChapter: TChapter) => { const nextChapter = await requestManager.getChapter(currentChapter.manga.id, chapterIndex).response; return nextChapter.data.chapter.chapterNumber === currentChapter.chapterNumber; }; /** * In case duplicated chapters should be skipped the function will check all next/prev chapters until * - a non duplicated chapter was found * - no prev/next chapter exists => chapter request will fail and error will be raised up */ const getOffsetChapter = async ( chapterIndex: number, currentChapter: TChapter, skipDupChapters: boolean, offset: ChapterOffset, ): Promise => { const shouldSkipChapter = skipDupChapters && (await isDupChapter(chapterIndex, currentChapter)); if (shouldSkipChapter) { return getOffsetChapter(chapterIndex + offset, currentChapter, skipDupChapters, offset); } return chapterIndex; }; 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 initialChapter = { pageCount: -1, sourceOrder: -1, chapterCount: 0, lastPageRead: 0, name: 'Loading...', } as unknown as TChapter; export default function Reader() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>(); const initialManga = useMemo( () => ({ id: +mangaId, title: '', thumbnailUrl: '', genre: [], inLibraryAt: 0, lastReadAt: 0, chapters: { totalCount: 0 }, }) as unknown as TManga, [mangaId], ); const { data, loading: isMangaLoading } = requestManager.useGetManga(mangaId); const loadedChapter = useRef(null); const isChapterLoaded = Number(mangaId) === loadedChapter.current?.manga.id && Number(chapterIndex) === loadedChapter.current?.sourceOrder && loadedChapter.current?.pageCount !== -1; const manga = data?.manga ?? initialManga; const { data: chapterData, loading: isChapterLoading } = requestManager.useGetMangaChapter(mangaId, chapterIndex, { skip: isChapterLoaded, }); const getLoadedChapter = () => { const isAChapterLoaded = loadedChapter.current; const isSameAsLoadedChapter = isAChapterLoaded && isChapterLoaded; if (isSameAsLoadedChapter) { return loadedChapter.current; } if (chapterData?.chapter) { return chapterData.chapter; } return null; }; loadedChapter.current = getLoadedChapter(); const chapter = loadedChapter.current ?? initialChapter; const [fetchPages, { loading: areChapterPagesLoading }] = requestManager.useGetChapterPagesFetch(chapter.id); useEffect(() => { if (!isChapterLoading && chapter.pageCount === -1) { fetchPages(); } }, [chapter.id]); const isLoading = isChapterLoading || areChapterPagesLoading || chapter.pageCount === -1; const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false); const [curPage, setCurPage] = useState(0); const [pageToScrollTo, setPageToScrollTo] = useState(undefined); const { setOverride, setTitle } = useContext(NavbarContext); const [retrievingNextChapter, setRetrievingNextChapter] = useState(false); const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings(); const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings)); const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => { setSettings({ ...settings, [key]: value }); requestUpdateMangaMetadata(manga, [[key, value]]).catch(() => makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'), ); }; const openNextChapter = useCallback( async (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => { setRetrievingNextChapter(true); try { setHistory( await getOffsetChapter(chapter.sourceOrder + offset, chapter, settings.skipDupChapters, offset), ); } catch (error) { const offsetToTranslationKeyMap: { [chapterOffset in ChapterOffset]: TranslationKey } = { [ChapterOffset.PREV]: 'reader.error.label.unable_to_get_prev_chapter_skip_dup', [ChapterOffset.NEXT]: 'reader.error.label.unable_to_get_next_chapter_skip_dup', }; makeToast(t(offsetToTranslationKeyMap[offset]), 'error'); } finally { setRetrievingNextChapter(false); } }, [chapter, settings], ); useEffect(() => { if (isLoading || !chapter) { 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(() => {}); setSettings(getReaderSettingsFor(manga, defaultSettings)); } }, [areDefaultSettingsLoading, isMangaLoading]); useEffect(() => { // set the custom navbar setOverride({ status: true, value: ( ), }); // clean up for when we leave the reader return () => setOverride({ status: false, value:
}); }, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter]); useEffect(() => { if (!wasLastPageReadSet) { 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 = curPage !== -1; const updateIsRead = curPage === chapter.pageCount - 1; const updateChapter = updateLastPageRead || updateIsRead; if (updateChapter) { requestManager.updateChapter(chapter.id, { lastPageRead: updateLastPageRead ? curPage : undefined, isRead: updateIsRead ? true : undefined, }); } }, [curPage]); const nextChapter = useCallback(() => { if (chapter.sourceOrder < manga.chapters.totalCount) { requestManager.updateChapter(chapter.id, { lastPageRead: chapter.pageCount - 1, isRead: true, }); openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) => navigate(`/manga/${manga.id}/chapter/${nextChapterIndex}`, { replace: true, state: location.state, }), ); } }, [chapter.sourceOrder, manga.chapters.totalCount, chapter.pageCount, manga.id, settings.skipDupChapters]); const prevChapter = useCallback(() => { if (chapter.sourceOrder > 1) { openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, { replace: true, state: location.state, }), ); } }, [chapter.sourceOrder, manga.id, settings.skipDupChapters]); // return spinner while chpater data is loading if (chapter.pageCount === -1) { return ( ); } 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 ( ); }