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",
"show_page_number": "Show page number",
"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",
"page_scale": {

View File

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

View File

@@ -13,8 +13,9 @@ import {
CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED,
CHAPTER_ACTION_TO_TRANSLATION,
} 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 };

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ export const ReaderBehaviourSettings = ({
settings,
updateSetting,
onDefault,
isDefaultable,
}: {
settings: IReaderSettingsWithDefaultFlag;
updateSetting: (
@@ -50,6 +51,21 @@ export const ReaderBehaviourSettings = ({
checked={settings.shouldSkipDupChapters}
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) && (
<CheckboxInput
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,
customFilter: undefined,
shouldSkipDupChapters: undefined,
shouldSkipFilteredChapters: undefined,
progressBarType: undefined,
progressBarSize: undefined,
progressBarPosition: undefined,
@@ -170,6 +171,7 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
shouldStretchPage: false,
shouldOffsetDoubleSpreads: false,
shouldSkipDupChapters: true,
shouldSkipFilteredChapters: false,
shouldShowPageNumber: true,
isStaticNav: false,
readingDirection: ReadingDirection.LTR,

View File

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

View File

@@ -18,14 +18,20 @@ import {
ReaderStateChapters,
} from '@/modules/reader/types/Reader.types.ts';
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 = (
chaptersResponse: ReturnType<typeof requestManager.useGetMangaChapters<GetChaptersReaderQuery>>,
chapterSourceOrder: number,
mangaChapters: ReaderStateChapters['mangaChapters'],
initialChapter: ReaderStateChapters['initialChapter'],
chapterForDuplicatesHandling: ReaderStateChapters['chapterForDuplicatesHandling'],
setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'],
shouldSkipDupChapters: IReaderSettings['shouldSkipDupChapters'],
shouldSkipFilteredChapters: IReaderSettings['shouldSkipFilteredChapters'],
chapterListOptions: ChapterListOptions,
) => {
const navigate = useNavigate();
const locationState = useLocation<ReaderOpenChapterLocationState>().state;
@@ -41,33 +47,30 @@ export const useReaderSetChaptersState = (
const newInitialChapter = finalInitialChapter ?? newCurrentChapter;
const newChapterForDuplicatesHandling = chapterForDuplicatesHandling ?? newCurrentChapter;
const nextChapter =
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 = (() => {
const visibleChapters = (() => {
if (!newMangaChapters || !newChapterForDuplicatesHandling) {
return [];
}
if (shouldSkipDupChapters) {
return Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters);
}
const filteredChapters = shouldSkipFilteredChapters
? 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;
@@ -77,10 +80,11 @@ export const useReaderSetChaptersState = (
setReaderStateChapters((prevState) => {
const hasCurrentChapterChanged = newCurrentChapter?.id !== prevState.currentChapter?.id;
return {
...prevState,
mangaChapters: newMangaChapters ?? [],
chapters: newChapters,
mangaChapters: prevState.mangaChapters ?? newMangaChapters,
chapters: visibleChapters,
initialChapter: newInitialChapter,
chapterForDuplicatesHandling: newChapterForDuplicatesHandling,
currentChapter: newCurrentChapter,
@@ -104,5 +108,12 @@ export const useReaderSetChaptersState = (
: 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 { useReaderSetChaptersState } from '@/modules/reader/hooks/useReaderSetChaptersState.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 = ({
setOverride,
@@ -61,6 +63,7 @@ const BaseReader = ({
manga,
setManga,
shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor,
readingMode,
tapZoneLayout,
@@ -68,6 +71,7 @@ const BaseReader = ({
shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview,
setSettings,
mangaChapters,
initialChapter,
chapterForDuplicatesHandling,
currentChapter,
@@ -87,12 +91,20 @@ const BaseReader = ({
Pick<TReaderStateSettingsContext, 'setSettings'> &
Pick<
IReaderSettings,
'shouldSkipDupChapters' | 'backgroundColor' | 'shouldShowReadingModePreview' | 'shouldShowTapZoneLayoutPreview'
| 'shouldSkipDupChapters'
| 'shouldSkipFilteredChapters'
| 'backgroundColor'
| 'shouldShowReadingModePreview'
| 'shouldShowTapZoneLayoutPreview'
> &
Pick<IReaderSettingsWithDefaultFlag, 'readingMode' | 'tapZoneLayout' | 'tapZoneInvertMode'> &
Pick<
ReaderStateChapters,
'initialChapter' | 'chapterForDuplicatesHandling' | 'currentChapter' | 'setReaderStateChapters'
| 'mangaChapters'
| 'initialChapter'
| 'chapterForDuplicatesHandling'
| 'currentChapter'
| 'setReaderStateChapters'
> &
Pick<
ReaderStatePages,
@@ -134,6 +146,7 @@ const BaseReader = ({
settings: defaultSettings,
request: defaultSettingsResponse,
} = useDefaultReaderSettings();
const chapterListOptions = useChapterListOptions(manga ?? FALLBACK_MANGA);
const isLoading =
currentChapter === undefined ||
@@ -183,10 +196,13 @@ const BaseReader = ({
useReaderSetChaptersState(
chaptersResponse,
chapterSourceOrder,
mangaChapters,
initialChapter,
chapterForDuplicatesHandling,
setReaderStateChapters,
shouldSkipDupChapters,
shouldSkipFilteredChapters,
chapterListOptions,
);
useLayoutEffect(() => {
@@ -296,12 +312,14 @@ export const Reader = withPropsFrom(
() => {
const {
shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor,
shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview,
} = ReaderService.useSettingsWithoutDefaultFlag();
return {
shouldSkipDupChapters,
shouldSkipFilteredChapters,
backgroundColor,
shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview,
@@ -323,6 +341,7 @@ export const Reader = withPropsFrom(
'manga',
'setManga',
'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'backgroundColor',
'readingMode',
'tapZoneLayout',
@@ -330,6 +349,7 @@ export const Reader = withPropsFrom(
'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview',
'setSettings',
'mangaChapters',
'initialChapter',
'chapterForDuplicatesHandling',
'currentChapter',

View File

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

View File

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

View File

@@ -144,6 +144,7 @@ export interface IReaderSettingsGlobal {
exitMode: ReaderExitMode;
customFilter: ReaderCustomFilter;
shouldSkipDupChapters: boolean;
shouldSkipFilteredChapters: boolean;
progressBarType: ProgressBarType;
/**
* pixel
@@ -202,14 +203,19 @@ export interface IReaderSettingsWithDefaultFlag
export interface ReaderStateChapters {
/**
* all chapters of the manga
*/
mangaChapters: TChapterReader[];
/**
* actual chapters that have been filtered
* All chapters of the manga with their state preserved from the initial reader load.
*
* optional filters:
* - removed duplicate chapters
* The state needs to be preserved so that chapter state changes do not lead to chapters getting filtered out.
* 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[];
chapterForDuplicatesHandling?: TChapterReader | null;