Feature/reader skip duplicate chapters (#262)

* Skip duplicate chapters in the Reader

* Debounce "end of page" handling in the reader

* Update keyboard event listener

In case the "go" function changed, the listeners have to be updated
This commit is contained in:
schroda
2023-04-05 13:37:12 +02:00
committed by GitHub
parent b1dc13cd30
commit d51150b784
11 changed files with 165 additions and 40 deletions

View File

@@ -25,7 +25,7 @@ import Collapse from '@mui/material/Collapse';
import { styled } from '@mui/system'; import { styled } from '@mui/system';
import useBackTo from 'util/useBackTo'; import useBackTo from 'util/useBackTo';
import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions'; import ReaderSettingsOptions from 'components/reader/ReaderSettingsOptions';
import { IChapter, IManga, IMangaCard, IReaderSettings } from 'typings'; import { ChapterOffset, IChapter, IManga, IMangaCard, IReaderSettings } from 'typings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const Root = styled('div')(({ theme }) => ({ const Root = styled('div')(({ theme }) => ({
@@ -119,6 +119,8 @@ interface IProps {
chapter: IChapter; chapter: IChapter;
curPage: number; curPage: number;
scrollToPage: (page: number) => void; scrollToPage: (page: number) => void;
openNextChapter: (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => Promise<void>;
retrievingNextChapter: boolean;
} }
export default function ReaderNavBar(props: IProps) { export default function ReaderNavBar(props: IProps) {
@@ -131,7 +133,8 @@ export default function ReaderNavBar(props: IProps) {
}>(); }>();
const { prevDrawerOpen, prevSettingsCollapseOpen } = location.state ?? {}; const { prevDrawerOpen, prevSettingsCollapseOpen } = location.state ?? {};
const { settings, setSettingValue, manga, chapter, curPage, scrollToPage } = props; const { settings, setSettingValue, manga, chapter, curPage, scrollToPage, openNextChapter, retrievingNextChapter } =
props;
const [drawerOpen, setDrawerOpen] = useState(settings.staticNav || prevDrawerOpen); const [drawerOpen, setDrawerOpen] = useState(settings.staticNav || prevDrawerOpen);
const [updateDrawerOnRender, setUpdateDrawerOnRender] = useState(true); const [updateDrawerOnRender, setUpdateDrawerOnRender] = useState(true);
@@ -139,6 +142,8 @@ export default function ReaderNavBar(props: IProps) {
const [prevScrollPos, setPrevScrollPos] = useState(0); const [prevScrollPos, setPrevScrollPos] = useState(0);
const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(prevSettingsCollapseOpen ?? true); const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(prevSettingsCollapseOpen ?? true);
const disableChapterNavButtons = retrievingNextChapter;
const updateSettingValue = (key: keyof IReaderSettings, value: string | boolean) => { const updateSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
// prevent closing the navBar when updating the "staticNav" setting // prevent closing the navBar when updating the "staticNav" setting
setUpdateDrawerOnRender(key !== 'staticNav'); setUpdateDrawerOnRender(key !== 'staticNav');
@@ -254,6 +259,7 @@ export default function ReaderNavBar(props: IProps) {
staticNav={settings.staticNav} staticNav={settings.staticNav}
showPageNumber={settings.showPageNumber} showPageNumber={settings.showPageNumber}
loadNextOnEnding={settings.loadNextOnEnding} loadNextOnEnding={settings.loadNextOnEnding}
skipDupChapters={settings.skipDupChapters}
readerType={settings.readerType} readerType={settings.readerType}
/> />
</Collapse> </Collapse>
@@ -261,7 +267,11 @@ export default function ReaderNavBar(props: IProps) {
<Navigation> <Navigation>
<PageNavigation> <PageNavigation>
<span>{t('reader.page_info.label.currently_on_page')}</span> <span>{t('reader.page_info.label.currently_on_page')}</span>
<FormControl size="small" sx={{ margin: '0 5px' }} disabled={chapter.pageCount === -1}> <FormControl
size="small"
sx={{ margin: '0 5px' }}
disabled={disableChapterNavButtons || chapter.pageCount === -1}
>
<Select <Select
MenuProps={MenuProps} MenuProps={MenuProps}
value={chapter.pageCount > -1 ? curPage : ''} value={chapter.pageCount > -1 ? curPage : ''}
@@ -286,20 +296,26 @@ export default function ReaderNavBar(props: IProps) {
<IconButton <IconButton
title={t('reader.button.previous_chapter')} title={t('reader.button.previous_chapter')}
sx={{ gridArea: 'pre' }} sx={{ gridArea: 'pre' }}
disabled={chapter.index <= 1} disabled={disableChapterNavButtons || chapter.index <= 1}
onClick={() => { onClick={() =>
history.replace({ openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => {
pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`, history.replace({
state: { pathname: `/manga/${manga.id}/chapter/${prevChapterIndex}`,
prevDrawerOpen: drawerOpen, state: {
prevSettingsCollapseOpen: settingsCollapseOpen, prevDrawerOpen: drawerOpen,
}, prevSettingsCollapseOpen: settingsCollapseOpen,
}); },
}} });
})
}
> >
<KeyboardArrowLeftIcon /> <KeyboardArrowLeftIcon />
</IconButton> </IconButton>
<FormControl sx={{ gridArea: 'current' }} size="small" disabled={chapter.index < 1}> <FormControl
sx={{ gridArea: 'current' }}
size="small"
disabled={disableChapterNavButtons || chapter.index < 1}
>
<Select <Select
MenuProps={MenuProps} MenuProps={MenuProps}
value={chapter.index >= 1 ? chapter.index : ''} value={chapter.index >= 1 ? chapter.index : ''}
@@ -328,15 +344,21 @@ export default function ReaderNavBar(props: IProps) {
<IconButton <IconButton
title={t('reader.button.next_chapter')} title={t('reader.button.next_chapter')}
sx={{ gridArea: 'next' }} sx={{ gridArea: 'next' }}
disabled={chapter.index < 1 || chapter.index >= chapter.chapterCount} disabled={
disableChapterNavButtons ||
chapter.index < 1 ||
chapter.index >= chapter.chapterCount
}
onClick={() => { onClick={() => {
history.replace({ openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`, history.replace({
state: { pathname: `/manga/${manga.id}/chapter/${nextChapterIndex}`,
prevDrawerOpen: drawerOpen, state: {
prevSettingsCollapseOpen: settingsCollapseOpen, prevDrawerOpen: drawerOpen,
}, prevSettingsCollapseOpen: settingsCollapseOpen,
}); },
}),
);
}} }}
> >
<KeyboardArrowRightIcon /> <KeyboardArrowRightIcon />

View File

@@ -23,6 +23,7 @@ export default function ReaderSettingsOptions({
loadNextOnEnding, loadNextOnEnding,
readerType, readerType,
showPageNumber, showPageNumber,
skipDupChapters,
setSettingValue, setSettingValue,
}: IProps) { }: IProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -59,6 +60,16 @@ export default function ReaderSettingsOptions({
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem>
<ListItemText primary={t('reader.settings.label.skip_dup_chapters')} />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={skipDupChapters}
onChange={(e) => setSettingValue('skipDupChapters', e.target.checked)}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem> <ListItem>
<ListItemText primary={t('reader.settings.label.reader_type')} /> <ListItemText primary={t('reader.settings.label.reader_type')} />
<Select <Select

View File

@@ -185,7 +185,7 @@ export default function DoublePagedPager(props: IReaderProps) {
document.removeEventListener('keydown', keyboardControl); document.removeEventListener('keydown', keyboardControl);
selfRef.current?.removeEventListener('click', clickControl); selfRef.current?.removeEventListener('click', clickControl);
}; };
}, [selfRef, curPage, settings.readerType]); }, [selfRef, curPage, settings.readerType, prevChapter, nextChapter]);
useEffect(() => { useEffect(() => {
setCurPage(initialPage); setCurPage(initialPage);

View File

@@ -134,7 +134,7 @@ export default function HorizontalPager(props: IReaderProps) {
document.removeEventListener('scroll', handleLoadNextonEnding); document.removeEventListener('scroll', handleLoadNextonEnding);
selfRef.current?.removeEventListener('mousedown', clickControl); selfRef.current?.removeEventListener('mousedown', clickControl);
}; };
}, [selfRef, curPage]); }, [selfRef, curPage, prevChapter, nextChapter]);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {

View File

@@ -85,7 +85,7 @@ export default function PagedReader(props: IReaderProps) {
document.removeEventListener('keydown', keyboardControl); document.removeEventListener('keydown', keyboardControl);
selfRef.current?.removeEventListener('click', clickControl); selfRef.current?.removeEventListener('click', clickControl);
}; };
}, [selfRef, curPage, settings.readerType]); }, [selfRef, curPage, settings.readerType, prevChapter, nextChapter]);
useEffect(() => { useEffect(() => {
// Delay scrolling to next cycle // Delay scrolling to next cycle

View File

@@ -42,10 +42,17 @@ export default function VerticalPager(props: IReaderProps) {
const pagesRef = useRef<HTMLDivElement[]>([]); const pagesRef = useRef<HTMLDivElement[]>([]);
useEffect(() => { useEffect(() => {
let handlingEndOfPage = false;
const handleScroll = () => { const handleScroll = () => {
if (!selfRef.current) return; if (!selfRef.current) return;
if (isAtBottom()) { if (isAtBottom()) {
if (handlingEndOfPage) {
return;
}
handlingEndOfPage = true;
// If scroll is moved all the way to the bottom // If scroll is moved all the way to the bottom
// This handles cases when last page is show, but is smaller then // This handles cases when last page is show, but is smaller then
// window, in which case it would never get marked as read. // window, in which case it would never get marked as read.
@@ -58,6 +65,8 @@ export default function VerticalPager(props: IReaderProps) {
nextChapter(); nextChapter();
} }
} else { } else {
handlingEndOfPage = false;
// Update current page in parent // Update current page in parent
const currentPage = findCurrentPageIndex(selfRef.current); const currentPage = findCurrentPageIndex(selfRef.current);
if (currentPage !== currentPageRef.current) { if (currentPage !== currentPageRef.current) {
@@ -71,7 +80,7 @@ export default function VerticalPager(props: IReaderProps) {
return () => { return () => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
}; };
}, [settings.loadNextOnEnding]); }, [settings.loadNextOnEnding, nextChapter]);
const go = useCallback( const go = useCallback(
(direction: 'up' | 'down') => { (direction: 'up' | 'down') => {
@@ -114,7 +123,7 @@ export default function VerticalPager(props: IReaderProps) {
return () => { return () => {
document.removeEventListener('keydown', handleKeyboard); document.removeEventListener('keydown', handleKeyboard);
}; };
}, []); }, [go]);
useEffect(() => { useEffect(() => {
// Delay scrolling to next cycle // Delay scrolling to next cycle

View File

@@ -304,6 +304,12 @@
"next_chapter": "Next Chapter", "next_chapter": "Next Chapter",
"previous_chapter": "Previous Chapter" "previous_chapter": "Previous Chapter"
}, },
"error": {
"label": {
"unable_to_get_next_chapter_skip_dup": "Unable to get the next chapter - \"$t(reader.settings.label.skip_dup_chapters)\" option is enabled",
"unable_to_get_prev_chapter_skip_dup": "Unable to get the previous chapter - \"$t(reader.settings.label.skip_dup_chapters)\" option is enabled"
}
},
"page_info": { "page_info": {
"label": { "label": {
"currently_on_page": "Currently on page", "currently_on_page": "Currently on page",
@@ -321,6 +327,7 @@
"load_next_chapter": "Load next chapter at ending", "load_next_chapter": "Load next chapter at ending",
"reader_type": "Reader Type", "reader_type": "Reader Type",
"show_page_number": "Show page number", "show_page_number": "Show page number",
"skip_dup_chapters": "Skip duplicate chapters",
"static_navigation": "Static Navigation" "static_navigation": "Static Navigation"
}, },
"reader_type": { "reader_type": {
@@ -424,4 +431,4 @@
}, },
"title": "Updates" "title": "Updates"
} }
} }

View File

@@ -25,9 +25,44 @@ import {
useDefaultReaderSettings, useDefaultReaderSettings,
} from 'util/readerSettings'; } from 'util/readerSettings';
import makeToast from 'components/util/Toast'; import makeToast from 'components/util/Toast';
import { IChapter, IManga, IMangaCard, IPartialChapter, IReaderSettings, ReaderType } from 'typings'; import {
ChapterOffset,
IChapter,
IManga,
IMangaCard,
IPartialChapter,
IReaderSettings,
ReaderType,
TranslationKey,
} from 'typings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const isDupChapter = async (chapterIndex: number, currentChapter: IChapter) => {
const nextChapter = (await client.get<IChapter>(`/api/v1/manga/${currentChapter.mangaId}/chapter/${chapterIndex}`))
.data;
return nextChapter.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: IChapter,
skipDupChapters: boolean,
offset: ChapterOffset,
): Promise<number> => {
const shouldSkipChapter = skipDupChapters && (await isDupChapter(chapterIndex, currentChapter));
if (shouldSkipChapter) {
return getOffsetChapter(chapterIndex + offset, currentChapter, skipDupChapters, offset);
}
return chapterIndex;
};
const getReaderComponent = (readerType: ReaderType) => { const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) { switch (readerType) {
case 'ContinuesVertical': case 'ContinuesVertical':
@@ -81,6 +116,7 @@ export default function Reader() {
const [curPage, setCurPage] = useState<number>(0); const [curPage, setCurPage] = useState<number>(0);
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined); const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
const { setOverride, setTitle } = useContext(NavbarContext); const { setOverride, setTitle } = useContext(NavbarContext);
const [retrievingNextChapter, setRetrievingNextChapter] = useState(false);
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings(); const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings)); const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings));
@@ -93,6 +129,32 @@ export default function Reader() {
); );
}; };
const openNextChapter = useCallback(
async (offset: ChapterOffset, setHistory: (nextChapterIndex: number) => void) => {
setRetrievingNextChapter(true);
try {
setHistory(
await getOffsetChapter(
chapter.index + offset,
chapter as IChapter,
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]) as string, 'error');
} finally {
setRetrievingNextChapter(false);
}
},
[chapter, settings],
);
useEffect(() => { useEffect(() => {
if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) { if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) {
setTitle(t('reader.title')); setTitle(t('reader.title'));
@@ -120,13 +182,15 @@ export default function Reader() {
chapter={chapter as IChapter} chapter={chapter as IChapter}
curPage={curPage} curPage={curPage}
scrollToPage={setPageToScrollTo} scrollToPage={setPageToScrollTo}
openNextChapter={openNextChapter}
retrievingNextChapter={retrievingNextChapter}
/> />
), ),
}); });
// clean up for when we leave the reader // clean up for when we leave the reader
return () => setOverride({ status: false, value: <div /> }); return () => setOverride({ status: false, value: <div /> });
}, [manga, chapter, settings, curPage, chapterIndex]); }, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter]);
useEffect(() => { useEffect(() => {
setIsMangaLoading(true); setIsMangaLoading(true);
@@ -175,21 +239,25 @@ export default function Reader() {
formData.append('read', 'true'); formData.append('read', 'true');
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData); client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
history.replace({ openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`, history.replace({
state: history.location.state, pathname: `/manga/${manga.id}/chapter/${nextChapterIndex}`,
}); state: history.location.state,
}),
);
} }
}, [chapter.index, chapter.chapterCount, chapter.pageCount, manga.id]); }, [chapter.index, chapter.chapterCount, chapter.pageCount, manga.id, settings.skipDupChapters]);
const prevChapter = useCallback(() => { const prevChapter = useCallback(() => {
if (chapter.index > 1) { if (chapter.index > 1) {
history.replace({ openNextChapter(ChapterOffset.PREV, (prevChapterIndex) =>
pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`, history.replace({
state: history.location.state, pathname: `/manga/${manga.id}/chapter/${prevChapterIndex}`,
}); state: history.location.state,
}),
);
} }
}, [chapter.index, manga.id]); }, [chapter.index, manga.id, settings.skipDupChapters]);
// return spinner while chpater data is loading // return spinner while chpater data is loading
if (chapter.pageCount === -1) { if (chapter.pageCount === -1) {

View File

@@ -62,6 +62,7 @@ export default function DefaultReaderSettings() {
staticNav={settings.staticNav} staticNav={settings.staticNav}
showPageNumber={settings.showPageNumber} showPageNumber={settings.showPageNumber}
loadNextOnEnding={settings.loadNextOnEnding} loadNextOnEnding={settings.loadNextOnEnding}
skipDupChapters={settings.skipDupChapters}
readerType={settings.readerType} readerType={settings.readerType}
/> />
); );

View File

@@ -202,9 +202,15 @@ export interface IReaderSettings {
staticNav: boolean; staticNav: boolean;
showPageNumber: boolean; showPageNumber: boolean;
loadNextOnEnding: boolean; loadNextOnEnding: boolean;
skipDupChapters: boolean;
readerType: ReaderType; readerType: ReaderType;
} }
export enum ChapterOffset {
PREV = -1,
NEXT = 1,
}
export interface ISearchSettings { export interface ISearchSettings {
ignoreFilters: boolean; ignoreFilters: boolean;
} }

View File

@@ -16,6 +16,7 @@ export const getDefaultSettings = (forceUndefined: boolean = false) =>
showPageNumber: forceUndefined ? undefined : true, showPageNumber: forceUndefined ? undefined : true,
continuesPageGap: forceUndefined ? undefined : false, continuesPageGap: forceUndefined ? undefined : false,
loadNextOnEnding: forceUndefined ? undefined : false, loadNextOnEnding: forceUndefined ? undefined : false,
skipDupChapters: forceUndefined ? undefined : true,
readerType: forceUndefined ? undefined : 'ContinuesVertical', readerType: forceUndefined ? undefined : 'ContinuesVertical',
} as IReaderSettings); } as IReaderSettings);