Add setting to apply chapter list filters in reader

This commit is contained in:
schroda
2025-07-01 00:37:29 +02:00
parent 21671f3d79
commit 51975ea3cd
13 changed files with 119 additions and 62 deletions

View File

@@ -995,7 +995,9 @@
"reading_mode": "Reading mode", "reading_mode": "Reading mode",
"show_page_number": "Show page number", "show_page_number": "Show page number",
"skip_dup_chapters": "Skip duplicate chapters", "skip_dup_chapters": "Skip duplicate chapters",
"static_navigation": "Static navigation" "skip_filtered_chapters": "Skip filtered chapters",
"static_navigation": "Static navigation",
"unchangeable_in_reader": "Setting can not be changed while the reader is opened"
}, },
"overlay_mode": "Overlay mode", "overlay_mode": "Overlay mode",
"page_scale": { "page_scale": {

View File

@@ -92,18 +92,25 @@ const sortChapters = <T extends TChapterSort>(
return sortedChapters; return sortedChapters;
}; };
type TChapterFilter = TChapterSort & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterScanlatorInfo; type TChapterFilter = ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterScanlatorInfo;
export function filterAndSortChapters<Chapters extends TChapterFilter>( export function filterChapters<Chapters extends TChapterFilter>(
chapters: Chapters[], chapters: Chapters[],
options: ChapterListOptions, options: ChapterListOptions,
): Chapters[] { ): Chapters[] {
const filtered = chapters.filter( return chapters.filter(
(chp) => (chp) =>
unreadFilter(options.unread, chp) && unreadFilter(options.unread, chp) &&
downloadFilter(options.downloaded, chp) && downloadFilter(options.downloaded, chp) &&
bookmarkedFilter(options.bookmarked, chp) && bookmarkedFilter(options.bookmarked, chp) &&
scanlatorFilter(options.excludedScanlators, chp), scanlatorFilter(options.excludedScanlators, chp),
); );
}
export function filterAndSortChapters<Chapters extends TChapterSort & TChapterFilter>(
chapters: Chapters[],
options: ChapterListOptions,
): Chapters[] {
const filtered = filterChapters(chapters, options);
return sortChapters(filtered, options); return sortChapters(filtered, options);
} }

View File

@@ -13,8 +13,9 @@ import {
CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED, CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED,
CHAPTER_ACTION_TO_TRANSLATION, CHAPTER_ACTION_TO_TRANSLATION,
} from '@/modules/chapter/Chapter.constants.ts'; } from '@/modules/chapter/Chapter.constants.ts';
import { GqlMetaHolder } from '@/modules/metadata/Metadata.types.ts';
export const FALLBACK_MANGA: MangaIdInfo = { id: -1 }; export const FALLBACK_MANGA: MangaIdInfo & GqlMetaHolder = { id: -1 };
export const GLOBAL_READER_SETTINGS_MANGA: MangaIdInfo = { id: -2 }; export const GLOBAL_READER_SETTINGS_MANGA: MangaIdInfo = { id: -2 };

View File

@@ -259,6 +259,9 @@ export const APP_METADATA: Record<
shouldSkipDupChapters: { shouldSkipDupChapters: {
convert: convertToBoolean, convert: convertToBoolean,
}, },
shouldSkipFilteredChapters: {
convert: convertToBoolean,
},
isStaticNav: { isStaticNav: {
convert: convertToBoolean, convert: convertToBoolean,
}, },
@@ -452,6 +455,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
'exitMode', 'exitMode',
'customFilter', 'customFilter',
'shouldSkipDupChapters', 'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'hotkeys', 'hotkeys',
'shouldShowTransitionPage', 'shouldShowTransitionPage',

View File

@@ -155,7 +155,8 @@ const BaseReaderSettingsTabs = ({
<ReaderBehaviourSettings <ReaderBehaviourSettings
settings={settings} settings={settings}
updateSetting={(...args) => updateSetting(...args)} updateSetting={(...args) => updateSetting(...args)}
isDefaultable // @ts-expect-error - TS2322: Type boolean is not assignable to type true
isDefaultable={!areDefaultSettings}
onDefault={(...args) => deleteSetting?.(...args)} onDefault={(...args) => deleteSetting?.(...args)}
/> />
</TabPanel> </TabPanel>

View File

@@ -27,6 +27,7 @@ export const ReaderBehaviourSettings = ({
settings, settings,
updateSetting, updateSetting,
onDefault, onDefault,
isDefaultable,
}: { }: {
settings: IReaderSettingsWithDefaultFlag; settings: IReaderSettingsWithDefaultFlag;
updateSetting: ( updateSetting: (
@@ -50,6 +51,21 @@ export const ReaderBehaviourSettings = ({
checked={settings.shouldSkipDupChapters} checked={settings.shouldSkipDupChapters}
onChange={(_, checked) => updateSetting('shouldSkipDupChapters', checked)} onChange={(_, checked) => updateSetting('shouldSkipDupChapters', checked)}
/> />
<CheckboxInput
label={
<Box>
<Typography>{t('reader.settings.label.skip_filtered_chapters')}</Typography>
{isDefaultable && (
<Typography variant="body2" color="textDisabled">
{t('reader.settings.label.unchangeable_in_reader')}
</Typography>
)}
</Box>
}
checked={settings.shouldSkipFilteredChapters}
onChange={(_, checked) => updateSetting('shouldSkipFilteredChapters', checked)}
disabled={isDefaultable}
/>
{isOffsetDoubleSpreadPagesEditable(settings.readingMode.value) && ( {isOffsetDoubleSpreadPagesEditable(settings.readingMode.value) && (
<CheckboxInput <CheckboxInput
label={t('reader.settings.label.offset_double_spread')} label={t('reader.settings.label.offset_double_spread')}

View File

@@ -135,6 +135,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefine
exitMode: undefined, exitMode: undefined,
customFilter: undefined, customFilter: undefined,
shouldSkipDupChapters: undefined, shouldSkipDupChapters: undefined,
shouldSkipFilteredChapters: undefined,
progressBarType: undefined, progressBarType: undefined,
progressBarSize: undefined, progressBarSize: undefined,
progressBarPosition: undefined, progressBarPosition: undefined,
@@ -170,6 +171,7 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
shouldStretchPage: false, shouldStretchPage: false,
shouldOffsetDoubleSpreads: false, shouldOffsetDoubleSpreads: false,
shouldSkipDupChapters: true, shouldSkipDupChapters: true,
shouldSkipFilteredChapters: false,
shouldShowPageNumber: true, shouldShowPageNumber: true,
isStaticNav: false, isStaticNav: false,
readingDirection: ReadingDirection.LTR, readingDirection: ReadingDirection.LTR,

View File

@@ -10,7 +10,6 @@ import { createContext, useContext } from 'react';
import { ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts'; import { ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts';
export const READER_STATE_CHAPTERS_DEFAULTS: Omit<ReaderStateChapters, 'setReaderStateChapters'> = { export const READER_STATE_CHAPTERS_DEFAULTS: Omit<ReaderStateChapters, 'setReaderStateChapters'> = {
mangaChapters: [],
chapters: [], chapters: [],
isCurrentChapterReady: false, isCurrentChapterReady: false,
visibleChapters: { visibleChapters: {

View File

@@ -18,14 +18,20 @@ import {
ReaderStateChapters, ReaderStateChapters,
} from '@/modules/reader/types/Reader.types.ts'; } from '@/modules/reader/types/Reader.types.ts';
import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx';
import { filterChapters } from '@/modules/chapter/utils/ChapterList.util.tsx';
import { ChapterListOptions } from '@/modules/chapter/Chapter.types.ts';
import { getReaderChapterFromCache } from '@/modules/reader/utils/Reader.utils.ts';
export const useReaderSetChaptersState = ( export const useReaderSetChaptersState = (
chaptersResponse: ReturnType<typeof requestManager.useGetMangaChapters<GetChaptersReaderQuery>>, chaptersResponse: ReturnType<typeof requestManager.useGetMangaChapters<GetChaptersReaderQuery>>,
chapterSourceOrder: number, chapterSourceOrder: number,
mangaChapters: ReaderStateChapters['mangaChapters'],
initialChapter: ReaderStateChapters['initialChapter'], initialChapter: ReaderStateChapters['initialChapter'],
chapterForDuplicatesHandling: ReaderStateChapters['chapterForDuplicatesHandling'], chapterForDuplicatesHandling: ReaderStateChapters['chapterForDuplicatesHandling'],
setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'], setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'],
shouldSkipDupChapters: IReaderSettings['shouldSkipDupChapters'], shouldSkipDupChapters: IReaderSettings['shouldSkipDupChapters'],
shouldSkipFilteredChapters: IReaderSettings['shouldSkipFilteredChapters'],
chapterListOptions: ChapterListOptions,
) => { ) => {
const navigate = useNavigate(); const navigate = useNavigate();
const locationState = useLocation<ReaderOpenChapterLocationState>().state; const locationState = useLocation<ReaderOpenChapterLocationState>().state;
@@ -41,33 +47,30 @@ export const useReaderSetChaptersState = (
const newInitialChapter = finalInitialChapter ?? newCurrentChapter; const newInitialChapter = finalInitialChapter ?? newCurrentChapter;
const newChapterForDuplicatesHandling = chapterForDuplicatesHandling ?? newCurrentChapter; const newChapterForDuplicatesHandling = chapterForDuplicatesHandling ?? newCurrentChapter;
const nextChapter = const visibleChapters = (() => {
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.NEXT,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
});
const previousChapter =
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.PREVIOUS,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
});
const newChapters = (() => {
if (!newMangaChapters || !newChapterForDuplicatesHandling) { if (!newMangaChapters || !newChapterForDuplicatesHandling) {
return []; return [];
} }
if (shouldSkipDupChapters) { const filteredChapters = shouldSkipFilteredChapters
return Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters); ? filterChapters(mangaChapters ?? newMangaChapters, chapterListOptions)
} : newMangaChapters;
const uniqueChapters = shouldSkipDupChapters
? Chapters.removeDuplicates(newChapterForDuplicatesHandling, filteredChapters)
: filteredChapters;
return newMangaChapters; return uniqueChapters.map((chapter) => getReaderChapterFromCache(chapter.id)!);
})(); })();
const nextChapter =
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, visibleChapters, {
offset: DirectionOffset.NEXT,
});
const previousChapter =
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, visibleChapters, {
offset: DirectionOffset.PREVIOUS,
});
const hasInitialChapterChanged = newInitialChapter != null && newInitialChapter.id !== finalInitialChapter?.id; const hasInitialChapterChanged = newInitialChapter != null && newInitialChapter.id !== finalInitialChapter?.id;
@@ -77,10 +80,11 @@ export const useReaderSetChaptersState = (
setReaderStateChapters((prevState) => { setReaderStateChapters((prevState) => {
const hasCurrentChapterChanged = newCurrentChapter?.id !== prevState.currentChapter?.id; const hasCurrentChapterChanged = newCurrentChapter?.id !== prevState.currentChapter?.id;
return { return {
...prevState, ...prevState,
mangaChapters: newMangaChapters ?? [], mangaChapters: prevState.mangaChapters ?? newMangaChapters,
chapters: newChapters, chapters: visibleChapters,
initialChapter: newInitialChapter, initialChapter: newInitialChapter,
chapterForDuplicatesHandling: newChapterForDuplicatesHandling, chapterForDuplicatesHandling: newChapterForDuplicatesHandling,
currentChapter: newCurrentChapter, currentChapter: newCurrentChapter,
@@ -104,5 +108,12 @@ export const useReaderSetChaptersState = (
: prevState.visibleChapters, : prevState.visibleChapters,
}; };
}); });
}, [chaptersResponse.data?.chapters.nodes, chapterSourceOrder, shouldSkipDupChapters, finalInitialChapter]); }, [
chaptersResponse.data?.chapters.nodes,
chapterSourceOrder,
shouldSkipDupChapters,
shouldSkipFilteredChapters,
finalInitialChapter,
chapterListOptions,
]);
}; };

View File

@@ -52,6 +52,8 @@ import { useReaderSetSettingsState } from '@/modules/reader/hooks/useReaderSetSe
import { useReaderShowSettingPreviewOnChange } from '@/modules/reader/hooks/useReaderShowSettingPreviewOnChange.ts'; import { useReaderShowSettingPreviewOnChange } from '@/modules/reader/hooks/useReaderShowSettingPreviewOnChange.ts';
import { useReaderSetChaptersState } from '@/modules/reader/hooks/useReaderSetChaptersState.ts'; import { useReaderSetChaptersState } from '@/modules/reader/hooks/useReaderSetChaptersState.ts';
import { useAppTitle } from '@/modules/navigation-bar/hooks/useAppTitle.ts'; import { useAppTitle } from '@/modules/navigation-bar/hooks/useAppTitle.ts';
import { useChapterListOptions } from '@/modules/chapter/utils/ChapterList.util.tsx';
import { FALLBACK_MANGA } from '@/modules/manga/Manga.constants.ts';
const BaseReader = ({ const BaseReader = ({
setOverride, setOverride,
@@ -61,6 +63,7 @@ const BaseReader = ({
manga, manga,
setManga, setManga,
shouldSkipDupChapters, shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor, backgroundColor,
readingMode, readingMode,
tapZoneLayout, tapZoneLayout,
@@ -68,6 +71,7 @@ const BaseReader = ({
shouldShowReadingModePreview, shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview, shouldShowTapZoneLayoutPreview,
setSettings, setSettings,
mangaChapters,
initialChapter, initialChapter,
chapterForDuplicatesHandling, chapterForDuplicatesHandling,
currentChapter, currentChapter,
@@ -87,12 +91,20 @@ const BaseReader = ({
Pick<TReaderStateSettingsContext, 'setSettings'> & Pick<TReaderStateSettingsContext, 'setSettings'> &
Pick< Pick<
IReaderSettings, IReaderSettings,
'shouldSkipDupChapters' | 'backgroundColor' | 'shouldShowReadingModePreview' | 'shouldShowTapZoneLayoutPreview' | 'shouldSkipDupChapters'
| 'shouldSkipFilteredChapters'
| 'backgroundColor'
| 'shouldShowReadingModePreview'
| 'shouldShowTapZoneLayoutPreview'
> & > &
Pick<IReaderSettingsWithDefaultFlag, 'readingMode' | 'tapZoneLayout' | 'tapZoneInvertMode'> & Pick<IReaderSettingsWithDefaultFlag, 'readingMode' | 'tapZoneLayout' | 'tapZoneInvertMode'> &
Pick< Pick<
ReaderStateChapters, ReaderStateChapters,
'initialChapter' | 'chapterForDuplicatesHandling' | 'currentChapter' | 'setReaderStateChapters' | 'mangaChapters'
| 'initialChapter'
| 'chapterForDuplicatesHandling'
| 'currentChapter'
| 'setReaderStateChapters'
> & > &
Pick< Pick<
ReaderStatePages, ReaderStatePages,
@@ -134,6 +146,7 @@ const BaseReader = ({
settings: defaultSettings, settings: defaultSettings,
request: defaultSettingsResponse, request: defaultSettingsResponse,
} = useDefaultReaderSettings(); } = useDefaultReaderSettings();
const chapterListOptions = useChapterListOptions(manga ?? FALLBACK_MANGA);
const isLoading = const isLoading =
currentChapter === undefined || currentChapter === undefined ||
@@ -183,10 +196,13 @@ const BaseReader = ({
useReaderSetChaptersState( useReaderSetChaptersState(
chaptersResponse, chaptersResponse,
chapterSourceOrder, chapterSourceOrder,
mangaChapters,
initialChapter, initialChapter,
chapterForDuplicatesHandling, chapterForDuplicatesHandling,
setReaderStateChapters, setReaderStateChapters,
shouldSkipDupChapters, shouldSkipDupChapters,
shouldSkipFilteredChapters,
chapterListOptions,
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -296,12 +312,14 @@ export const Reader = withPropsFrom(
() => { () => {
const { const {
shouldSkipDupChapters, shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor, backgroundColor,
shouldShowReadingModePreview, shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview, shouldShowTapZoneLayoutPreview,
} = ReaderService.useSettingsWithoutDefaultFlag(); } = ReaderService.useSettingsWithoutDefaultFlag();
return { return {
shouldSkipDupChapters, shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor, backgroundColor,
shouldShowReadingModePreview, shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview, shouldShowTapZoneLayoutPreview,
@@ -323,6 +341,7 @@ export const Reader = withPropsFrom(
'manga', 'manga',
'setManga', 'setManga',
'shouldSkipDupChapters', 'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'backgroundColor', 'backgroundColor',
'readingMode', 'readingMode',
'tapZoneLayout', 'tapZoneLayout',
@@ -330,6 +349,7 @@ export const Reader = withPropsFrom(
'shouldShowReadingModePreview', 'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview', 'shouldShowTapZoneLayoutPreview',
'setSettings', 'setSettings',
'mangaChapters',
'initialChapter', 'initialChapter',
'chapterForDuplicatesHandling', 'chapterForDuplicatesHandling',
'currentChapter', 'currentChapter',

View File

@@ -475,32 +475,22 @@ export class ReaderControls {
endReached?: boolean, endReached?: boolean,
) => void { ) => void {
const { currentPageIndex, setCurrentPageIndex } = userReaderStatePagesContext(); const { currentPageIndex, setCurrentPageIndex } = userReaderStatePagesContext();
const { const { currentChapter, chapters, previousChapter, nextChapter, visibleChapters, setReaderStateChapters } =
chapterForDuplicatesHandling, useReaderStateChaptersContext();
currentChapter,
previousChapter,
nextChapter,
mangaChapters,
visibleChapters,
setReaderStateChapters,
} = useReaderStateChaptersContext();
const updateChapter = ReaderService.useUpdateChapter(); const updateChapter = ReaderService.useUpdateChapter();
const { shouldSkipDupChapters } = ReaderService.useSettings();
const { const {
settings: { downloadAheadLimit }, settings: { downloadAheadLimit },
} = useMetadataServerSettings(); } = useMetadataServerSettings();
const nextChapters = useMemo(() => { const nextChapters = useMemo(() => {
if (!chapterForDuplicatesHandling || !currentChapter) { if (!currentChapter) {
return []; return [];
} }
return Chapters.getNextChapters(currentChapter, mangaChapters, { return Chapters.getNextChapters(currentChapter, chapters, {
offset: DirectionOffset.NEXT, offset: DirectionOffset.NEXT,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: chapterForDuplicatesHandling,
}); });
}, [chapterForDuplicatesHandling?.id, currentChapter?.id, mangaChapters, shouldSkipDupChapters]); }, [currentChapter?.id, chapters]);
return useCallback( return useCallback(
(pageIndex, debounceChapterUpdate = true, endReached = false) => { (pageIndex, debounceChapterUpdate = true, endReached = false) => {

View File

@@ -157,27 +157,25 @@ export class ReaderService {
static useUpdateChapter(): (patch: UpdateChapterPatchInput) => void { static useUpdateChapter(): (patch: UpdateChapterPatchInput) => void {
const { manga } = useReaderStateMangaContext(); const { manga } = useReaderStateMangaContext();
const { chapterForDuplicatesHandling, currentChapter, mangaChapters } = useReaderStateChaptersContext(); const { currentChapter, mangaChapters, chapters } = useReaderStateChaptersContext();
const { shouldSkipDupChapters } = ReaderService.useSettings(); const { shouldSkipDupChapters } = ReaderService.useSettings();
const { const {
settings: { deleteChaptersWhileReading, deleteChaptersWithBookmark, updateProgressAfterReading }, settings: { deleteChaptersWhileReading, deleteChaptersWithBookmark, updateProgressAfterReading },
} = useMetadataServerSettings(); } = useMetadataServerSettings();
const previousChapters = useMemo(() => { const previousChapters = useMemo(() => {
if (!chapterForDuplicatesHandling || !currentChapter) { if (!currentChapter) {
return []; return [];
} }
return Chapters.getNextChapters(currentChapter, mangaChapters, { return Chapters.getNextChapters(currentChapter, chapters, {
offset: DirectionOffset.PREVIOUS, offset: DirectionOffset.PREVIOUS,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: chapterForDuplicatesHandling,
}); });
}, [chapterForDuplicatesHandling?.id, currentChapter?.id, mangaChapters, shouldSkipDupChapters]); }, [currentChapter?.id, chapters]);
return useCallback( return useCallback(
(patch) => { (patch) => {
if (!manga || !currentChapter) { if (!manga || !currentChapter || !mangaChapters) {
return; return;
} }

View File

@@ -144,6 +144,7 @@ export interface IReaderSettingsGlobal {
exitMode: ReaderExitMode; exitMode: ReaderExitMode;
customFilter: ReaderCustomFilter; customFilter: ReaderCustomFilter;
shouldSkipDupChapters: boolean; shouldSkipDupChapters: boolean;
shouldSkipFilteredChapters: boolean;
progressBarType: ProgressBarType; progressBarType: ProgressBarType;
/** /**
* pixel * pixel
@@ -202,14 +203,19 @@ export interface IReaderSettingsWithDefaultFlag
export interface ReaderStateChapters { export interface ReaderStateChapters {
/** /**
* all chapters of the manga * All chapters of the manga with their state preserved from the initial reader load.
*/
mangaChapters: TChapterReader[];
/**
* actual chapters that have been filtered
* *
* optional filters: * The state needs to be preserved so that chapter state changes do not lead to chapters getting filtered out.
* - removed duplicate chapters * E.g., in case read chapters are filtered out. This would then lead to the removal of the current chapter
* after the end of the chapter is reached.
*/
mangaChapters?: TChapterReader[];
/**
* Actual chapters that have been filtered
*
* Optional filters:
* - Removed duplicate chapters
* - Manga chapter list filters
*/ */
chapters: TChapterReader[]; chapters: TChapterReader[];
chapterForDuplicatesHandling?: TChapterReader | null; chapterForDuplicatesHandling?: TChapterReader | null;