Files
suwayomi-material-you-webui/src/screens/Reader.tsx

429 lines
16 KiB
TypeScript
Raw Normal View History

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
2021-01-26 23:32:12 +03:30
* 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/.
*/
2021-01-26 23:32:12 +03:30
2021-09-09 17:51:22 +04:30
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 {
AllowedMetadataValueTypes,
ChapterOffset,
IReaderSettings,
ReaderType,
TChapter,
TManga,
TranslationKey,
} from '@/typings';
2023-10-28 00:32:02 +02:00
import { requestManager } from '@/lib/requests/RequestManager.ts';
import {
checkAndHandleMissingStoredReaderSettings,
getReaderSettingsFor,
useDefaultReaderSettings,
} from '@/util/readerSettings';
import { requestUpdateMangaMetadata } from '@/util/metadata';
2023-10-28 00:32:02 +02:00
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 { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { FULL_CHAPTER_FIELDS } from '@/lib/graphql/Fragments.ts';
2021-01-20 01:05:24 +03:30
2023-10-15 16:03:08 +02:00
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,
2023-10-15 16:03:08 +02:00
currentChapter: TChapter,
skipDupChapters: boolean,
offset: ChapterOffset,
): Promise<number> => {
const shouldSkipChapter = skipDupChapters && (await isDupChapter(chapterIndex, currentChapter));
if (shouldSkipChapter) {
return getOffsetChapter(chapterIndex + offset, currentChapter, skipDupChapters, offset);
}
return chapterIndex;
};
2021-05-15 23:22:37 +04:30
const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) {
case 'ContinuesVertical':
case 'Webtoon':
return VerticalPager;
2021-05-15 23:22:37 +04:30
break;
case 'SingleVertical':
case 'SingleRTL':
case 'SingleLTR':
return PagedPager;
break;
case 'DoubleVertical':
case 'DoubleRTL':
case 'DoubleLTR':
return DoublePagedPager;
2021-05-15 23:22:37 +04:30
break;
case 'ContinuesHorizontalLTR':
case 'ContinuesHorizontalRTL':
return HorizontalPager;
2021-05-15 23:22:37 +04:30
default:
return VerticalPager;
2021-05-15 23:22:37 +04:30
break;
}
};
const range = (n: number) => Array.from({ length: n }, (value, key) => key);
const initialChapter = {
pageCount: -1,
sourceOrder: -1,
chapterCount: 0,
lastPageRead: 0,
name: 'Loading...',
2023-10-15 16:03:08 +02:00
} as unknown as TChapter;
2021-01-22 17:00:33 +03:30
2023-10-28 00:32:02 +02:00
export function Reader() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
2021-03-09 16:44:09 +03:30
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string; mangaId: string }>();
2023-09-08 00:44:01 +02:00
const initialManga = useMemo(
() =>
({
id: +mangaId,
title: '',
thumbnailUrl: '',
genre: [],
inLibraryAt: 0,
lastReadAt: 0,
chapters: { totalCount: 0 },
2023-10-15 16:03:08 +02:00
}) as unknown as TManga,
2023-09-08 00:44:01 +02:00
[mangaId],
);
const { data, loading: isMangaLoading } = requestManager.useGetManga(mangaId);
2023-10-15 16:03:08 +02:00
const loadedChapter = useRef<TChapter | null>(null);
const isChapterLoaded =
Number(mangaId) === loadedChapter.current?.manga.id &&
Number(chapterIndex) === loadedChapter.current?.sourceOrder &&
loadedChapter.current?.pageCount !== -1;
2023-10-15 16:03:08 +02:00
const manga = data?.manga ?? initialManga;
const { data: chapterData, loading: isChapterLoading } = requestManager.useGetMangaChapter(mangaId, chapterIndex, {
skip: isChapterLoaded,
});
const [arePagesUpdated, setArePagesUpdated] = useState(false);
const { data: settingsData } = requestManager.useGetServerSettings();
const isDownloadAheadEnabled = !!settingsData?.settings.autoDownloadAheadLimit;
const getLoadedChapter = () => {
const isAChapterLoaded = loadedChapter.current;
const isSameAsLoadedChapter = isAChapterLoaded && isChapterLoaded;
if (isSameAsLoadedChapter) {
return loadedChapter.current;
}
if (arePagesUpdated) {
setArePagesUpdated(false);
}
if (chapterData?.chapter) {
2023-10-15 16:03:08 +02:00
return chapterData.chapter;
}
return null;
};
loadedChapter.current = getLoadedChapter();
const chapter = loadedChapter.current ?? initialChapter;
const [fetchPages] = requestManager.useGetChapterPagesFetch(chapter.id);
useEffect(() => {
const reCheckPages = !chapter.isDownloaded || chapter.pageCount === -1;
const shouldFetchPages = !isChapterLoading && reCheckPages;
if (shouldFetchPages) {
fetchPages().then(() => setArePagesUpdated(true));
}
if (!reCheckPages && !arePagesUpdated) {
setArePagesUpdated(true);
}
}, [chapter.id]);
const isLoading = isChapterLoading || !arePagesUpdated;
const [wasLastPageReadSet, setWasLastPageReadSet] = useState(false);
2021-03-19 14:52:20 +03:30
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);
2023-10-28 00:32:02 +02:00
const { setOverride, setTitle } = useContext(NavBarContext);
const [retrievingNextChapter, setRetrievingNextChapter] = useState(false);
const { data: mangaChaptersData } = requestManager.useGetMangaChapters(mangaId, { nextFetchPolicy: 'standby' });
const mangaChapters = mangaChaptersData?.chapters.nodes;
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings));
const { settings: metadataSettings } = useMetadataServerSettings();
const updateChapter = (patch: UpdateChapterPatchInput) => {
const isAutoDeletionEnabled = !!patch.isRead && !!metadataSettings.deleteChaptersWhileReading;
const getChapterIdToDelete = () => {
if (!isAutoDeletionEnabled || !mangaChapters) {
return -1;
}
const chapterToDeleteSourceOrder = Number(chapterIndex) - (metadataSettings.deleteChaptersWhileReading - 1);
const chapterToDelete = mangaChapters.find(
(mangaChapter) => mangaChapter.sourceOrder === chapterToDeleteSourceOrder,
);
if (!chapterToDelete) {
return -1;
}
const chapterToDeleteUpToDateData = requestManager.graphQLClient.client.cache.readFragment<TChapter>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'ChapterType',
id: chapterToDelete.id,
}),
fragment: FULL_CHAPTER_FIELDS,
fragmentName: 'FULL_CHAPTER_FIELDS',
});
const shouldDeleteChapter =
chapterToDeleteUpToDateData?.isDownloaded &&
(!chapterToDeleteUpToDateData?.isBookmarked || metadataSettings.deleteChaptersWithBookmark);
if (!shouldDeleteChapter) {
return -1;
}
return chapterToDelete.id;
};
const shouldDownloadAhead =
chapter.manga.inLibrary && !chapter.isRead && !!patch.isRead && isDownloadAheadEnabled;
requestManager
.updateChapter(chapter.id, {
...patch,
chapterIdToDelete: getChapterIdToDelete(),
downloadAheadMangaId: shouldDownloadAhead ? chapter.manga.id : undefined,
})
.response.catch();
};
const setSettingValue = (key: keyof IReaderSettings, value: AllowedMetadataValueTypes) => {
Save reader settings per manga in Meta (#216) * [#213] Add missing "meta" property - IManga - IMangaCard (optional) - IChapter - ICategory * [#213] Add util functions for handling metadata - global server - manga - chapter - category * [#213] Use "ReaderSettings" from the manga metadata - get ReaderSettings from manga metadata - remove unnecessary check "make sure settings has all the keys" in case the stored settings in the metadata are outdated they will be filled with default values * [#213] Reload manga only in case "mangaId" changed In case the chapter is still from the same manga, a reload is unnecessary * [#213] Only update the changed reader setting Otherwise the app has to send patch requests to the server for every setting even if it didn't change * [#213] Hide "openButton" on first render in case "navBar" is shown The "openButton" was always set to be visible event in case the navBar was shown on the first render * [#213] Open the "ReaderNavBar" if needed after receiving new settings Otherwise, the drawer won't open in case the "navBar" was hidden on the first render since the default state in that case was set to false * [#213] Update "ReaderSettings" after receiving the manga response Otherwise, the default settings aren't getting removed * [#213] Hide/Show "OpenButton" when opening/closing the drawer Otherwise, the button stays hidden until it gets updated due to scrolling * [#213] Keep "ReaderNavBar" state when opening prev/next chapter In case the navBar wasn't sticky but opened, it got closed when the prev/next chapter got opened * [#213] Prevent "ReaderNavBar" from closing when changing "staticNav" setting
2023-01-06 17:15:29 +01:00
setSettings({ ...settings, [key]: value });
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() =>
makeToast(t('reader.settings.error.label.failed_to_save_settings'), 'warning'),
);
Save reader settings per manga in Meta (#216) * [#213] Add missing "meta" property - IManga - IMangaCard (optional) - IChapter - ICategory * [#213] Add util functions for handling metadata - global server - manga - chapter - category * [#213] Use "ReaderSettings" from the manga metadata - get ReaderSettings from manga metadata - remove unnecessary check "make sure settings has all the keys" in case the stored settings in the metadata are outdated they will be filled with default values * [#213] Reload manga only in case "mangaId" changed In case the chapter is still from the same manga, a reload is unnecessary * [#213] Only update the changed reader setting Otherwise the app has to send patch requests to the server for every setting even if it didn't change * [#213] Hide "openButton" on first render in case "navBar" is shown The "openButton" was always set to be visible event in case the navBar was shown on the first render * [#213] Open the "ReaderNavBar" if needed after receiving new settings Otherwise, the drawer won't open in case the "navBar" was hidden on the first render since the default state in that case was set to false * [#213] Update "ReaderSettings" after receiving the manga response Otherwise, the default settings aren't getting removed * [#213] Hide/Show "OpenButton" when opening/closing the drawer Otherwise, the button stays hidden until it gets updated due to scrolling * [#213] Keep "ReaderNavBar" state when opening prev/next chapter In case the navBar wasn't sticky but opened, it got closed when the prev/next chapter got opened * [#213] Prevent "ReaderNavBar" from closing when changing "staticNav" setting
2023-01-06 17:15:29 +01:00
};
const openNextChapter = useCallback(
async (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => {
setRetrievingNextChapter(true);
setCurPage(0);
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) {
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]);
Save reader settings per manga in Meta (#216) * [#213] Add missing "meta" property - IManga - IMangaCard (optional) - IChapter - ICategory * [#213] Add util functions for handling metadata - global server - manga - chapter - category * [#213] Use "ReaderSettings" from the manga metadata - get ReaderSettings from manga metadata - remove unnecessary check "make sure settings has all the keys" in case the stored settings in the metadata are outdated they will be filled with default values * [#213] Reload manga only in case "mangaId" changed In case the chapter is still from the same manga, a reload is unnecessary * [#213] Only update the changed reader setting Otherwise the app has to send patch requests to the server for every setting even if it didn't change * [#213] Hide "openButton" on first render in case "navBar" is shown The "openButton" was always set to be visible event in case the navBar was shown on the first render * [#213] Open the "ReaderNavBar" if needed after receiving new settings Otherwise, the drawer won't open in case the "navBar" was hidden on the first render since the default state in that case was set to false * [#213] Update "ReaderSettings" after receiving the manga response Otherwise, the default settings aren't getting removed * [#213] Hide/Show "OpenButton" when opening/closing the drawer Otherwise, the button stays hidden until it gets updated due to scrolling * [#213] Keep "ReaderNavBar" state when opening prev/next chapter In case the navBar wasn't sticky but opened, it got closed when the prev/next chapter got opened * [#213] Prevent "ReaderNavBar" from closing when changing "staticNav" setting
2023-01-06 17:15:29 +01:00
useEffect(() => {
// set the custom navbar
setOverride({
status: true,
value: (
<ReaderNavBar
settings={settings}
setSettingValue={setSettingValue}
manga={manga}
chapter={chapter}
curPage={curPage}
scrollToPage={setPageToScrollTo}
openNextChapter={openNextChapter}
retrievingNextChapter={retrievingNextChapter}
/>
),
});
2021-03-18 21:46:24 +03:30
// clean up for when we leave the reader
return () => setOverride({ status: false, value: <div /> });
}, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter]);
2021-03-18 21:46:24 +03:30
2021-05-18 02:26:45 +04:30
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 = curPageDebounced !== -1;
const updateIsRead = curPageDebounced === chapter.pageCount - 1;
const shouldUpdateChapter = updateLastPageRead || updateIsRead;
if (!shouldUpdateChapter) {
return;
2021-05-18 02:26:45 +04:30
}
updateChapter({
lastPageRead: updateLastPageRead ? curPageDebounced : undefined,
isRead: updateIsRead ? true : undefined,
});
}, [curPageDebounced, isDownloadAheadEnabled]);
2021-05-18 02:26:45 +04:30
const nextChapter = useCallback(() => {
const doesNextChapterExist = chapter.sourceOrder < manga.chapters.totalCount;
if (!doesNextChapterExist) {
return;
}
updateChapter({
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,
isDownloadAheadEnabled,
]);
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]);
if (isLoading) {
return (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'grid',
placeItems: 'center',
}}
>
<CircularProgress thickness={5} />
</Box>
);
}
2021-05-15 17:18:57 +04:30
const pages = range(chapter.pageCount).map((index) => ({
index,
src: requestManager.getChapterPageUrl(mangaId, chapterIndex, index),
2021-05-15 17:18:57 +04:30
}));
2021-05-15 23:22:37 +04:30
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);
2021-01-20 01:05:24 +03:30
return (
<Box
sx={{
Fix reader width (#567) * Fix reader width The margin: auto on the Box container for the image was preventing 100% width to actually mean 100%. Now that 100% is actually possible, I think fitPageToWindow makes more sense as a default. Additionally, since the image can fill 100% of the page, it can cover the ReaderNavBar, so set the z-index of the ReaderNavBar so it's rendered on top of the image and clickable. Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com> * Support configuring reader width for DoublePage readers Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com> * Fix single page of DoublePageReader not being able to take up full width In case the parent container is flex row, the container does not automatically take up 100% of the available width, thus, the page also was not able to take up 100% of the width * Fix applying reader width to double pages The set reader width can't be applied to each page of the double pages because otherwise it will already take up 100% of the available width with the setting only being at 50%. Instead, the set width has to be divided by 2, so that both pages take up the set reader width * Prevent double page spinner from being larger than 100% of the available width * Update width styling of the page spinner * Always center pages in the middle of the screen * Take up full height fitting page to window height --------- Signed-off-by: Chance Zibolski <chance.zibolski@gmail.com> Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2024-01-26 11:51:08 -08:00
display: 'flex',
flexDirection: 'column',
alignContent: 'center',
justifyContent: 'center',
minWidth: settings.staticNav ? 'calc((100vw - (100vw - 100%)) - 300px)' : '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} />
2021-05-15 23:22:37 +04:30
<ReaderComponent
key={chapter.id}
2021-05-15 18:17:12 +04:30
pages={pages}
2021-05-15 23:22:37 +04:30
pageCount={chapter.pageCount}
2021-05-15 18:17:12 +04:30
setCurPage={setCurPage}
initialPage={initialPage}
2021-05-15 18:17:12 +04:30
curPage={curPage}
settings={settings}
manga={manga}
chapter={chapter}
nextChapter={nextChapter}
prevChapter={prevChapter}
2021-05-15 17:18:57 +04:30
/>
2021-12-04 10:29:36 +03:30
</Box>
2021-01-20 01:05:24 +03:30
);
}