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

View File

@@ -23,6 +23,7 @@ export default function ReaderSettingsOptions({
loadNextOnEnding,
readerType,
showPageNumber,
skipDupChapters,
setSettingValue,
}: IProps) {
const { t } = useTranslation();
@@ -59,6 +60,16 @@ export default function ReaderSettingsOptions({
/>
</ListItemSecondaryAction>
</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>
<ListItemText primary={t('reader.settings.label.reader_type')} />
<Select

View File

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

View File

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

View File

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

View File

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

View File

@@ -304,6 +304,12 @@
"next_chapter": "Next 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": {
"label": {
"currently_on_page": "Currently on page",
@@ -321,6 +327,7 @@
"load_next_chapter": "Load next chapter at ending",
"reader_type": "Reader Type",
"show_page_number": "Show page number",
"skip_dup_chapters": "Skip duplicate chapters",
"static_navigation": "Static Navigation"
},
"reader_type": {
@@ -424,4 +431,4 @@
},
"title": "Updates"
}
}
}

View File

@@ -25,9 +25,44 @@ import {
useDefaultReaderSettings,
} from 'util/readerSettings';
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';
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) => {
switch (readerType) {
case 'ContinuesVertical':
@@ -81,6 +116,7 @@ export default function Reader() {
const [curPage, setCurPage] = useState<number>(0);
const [pageToScrollTo, setPageToScrollTo] = useState<number | undefined>(undefined);
const { setOverride, setTitle } = useContext(NavbarContext);
const [retrievingNextChapter, setRetrievingNextChapter] = useState(false);
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
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(() => {
if (!manga?.title || (chapter as IChapter)?.name === t('global.label.loading')) {
setTitle(t('reader.title'));
@@ -120,13 +182,15 @@ export default function Reader() {
chapter={chapter as IChapter}
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]);
}, [manga, chapter, settings, curPage, chapterIndex, retrievingNextChapter]);
useEffect(() => {
setIsMangaLoading(true);
@@ -175,21 +239,25 @@ export default function Reader() {
formData.append('read', 'true');
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
history.replace({
pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`,
state: history.location.state,
});
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
history.replace({
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(() => {
if (chapter.index > 1) {
history.replace({
pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`,
state: history.location.state,
});
openNextChapter(ChapterOffset.PREV, (prevChapterIndex) =>
history.replace({
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
if (chapter.pageCount === -1) {

View File

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

View File

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

View File

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