diff --git a/public/locales/en.json b/public/locales/en.json index fe891b1e..a4094eb5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -844,6 +844,10 @@ }, "settings": { "active_setting": "Active setting", + "auto_webtoon_mode": { + "description": "Automatically use webtoon mode for entries that are detected to likely use the long strip format", + "title": "Auto webtoon mode" + }, "background_color": "Background color", "custom_filter": { "brightness": "Custom brightness", diff --git a/src/lib/graphql/fragments/MangaFragments.ts b/src/lib/graphql/fragments/MangaFragments.ts index fd59143f..e326f99a 100644 --- a/src/lib/graphql/fragments/MangaFragments.ts +++ b/src/lib/graphql/fragments/MangaFragments.ts @@ -7,6 +7,7 @@ */ import gql from 'graphql-tag'; +import { SOURCE_BASE_FIELDS } from '@/lib/graphql/fragments/SourceFragments.ts'; export const MANGA_META_FIELDS = gql` fragment MANGA_META_FIELDS on MangaMetaType { @@ -75,10 +76,16 @@ export const MANGA_CHAPTER_NODE_FIELDS = gql` export const MANGA_READER_FIELDS = gql` ${MANGA_BASE_FIELDS} ${MANGA_META_FIELDS} + ${SOURCE_BASE_FIELDS} fragment MANGA_READER_FIELDS on MangaType { ...MANGA_BASE_FIELDS + genre + source { + ...SOURCE_BASE_FIELDS + } + meta { ...MANGA_META_FIELDS } diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 9fed5804..545ad400 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2801,7 +2801,7 @@ export type MangaChapterStatFieldsFragment = { __typename?: 'MangaType', id: num export type MangaChapterNodeFieldsFragment = { __typename?: 'MangaType', firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null }; -export type MangaReaderFieldsFragment = { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string, meta: Array<{ __typename?: 'MangaMetaType', mangaId: number, key: string, value: string }>, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, trackRecords: { __typename?: 'TrackRecordNodeList', totalCount: number } }; +export type MangaReaderFieldsFragment = { __typename?: 'MangaType', genre: Array, id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string, source?: { __typename?: 'SourceType', id: string, name: string, displayName: string } | null, meta: Array<{ __typename?: 'MangaMetaType', mangaId: number, key: string, value: string }>, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, trackRecords: { __typename?: 'TrackRecordNodeList', totalCount: number } }; export type MangaLibraryFieldsFragment = { __typename?: 'MangaType', genre: Array, lastFetchedAt?: string | null, inLibraryAt: string, status: MangaStatus, artist?: string | null, author?: string | null, description?: string | null, id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string, unreadCount: number, downloadCount: number, bookmarkCount: number, hasDuplicateChapters: boolean, source?: { __typename?: 'SourceType', id: string, displayName: string } | null, trackRecords: { __typename?: 'TrackRecordNodeList', totalCount: number, nodes: Array<{ __typename?: 'TrackRecordType', id: number, trackerId: number }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, firstUnreadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, isRead: boolean, mangaId: number } | null, lastReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number, sourceOrder: number, lastReadAt: string } | null, latestFetchedChapter?: { __typename?: 'ChapterType', id: number, fetchedAt: string } | null, latestUploadedChapter?: { __typename?: 'ChapterType', id: number, uploadDate: string } | null }; @@ -3441,7 +3441,7 @@ export type GetMangaReaderQueryVariables = Exact<{ }>; -export type GetMangaReaderQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string, meta: Array<{ __typename?: 'MangaMetaType', mangaId: number, key: string, value: string }>, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, trackRecords: { __typename?: 'TrackRecordNodeList', totalCount: number } } }; +export type GetMangaReaderQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', genre: Array, id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string, source?: { __typename?: 'SourceType', id: string, name: string, displayName: string } | null, meta: Array<{ __typename?: 'MangaMetaType', mangaId: number, key: string, value: string }>, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, trackRecords: { __typename?: 'TrackRecordNodeList', totalCount: number } } }; export type GetMangaTrackRecordsQueryVariables = Exact<{ id: Scalars['Int']['input']; diff --git a/src/modules/core/components/inputs/CheckboxInput.tsx b/src/modules/core/components/inputs/CheckboxInput.tsx index c0e96d91..6b41917b 100644 --- a/src/modules/core/components/inputs/CheckboxInput.tsx +++ b/src/modules/core/components/inputs/CheckboxInput.tsx @@ -7,11 +7,11 @@ */ import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControlLabel, { FormControlLabelProps } from '@mui/material/FormControlLabel'; import React from 'react'; interface IProps extends CheckboxProps { - label?: string; + label?: FormControlLabelProps['label']; } export const CheckboxInput: React.FC = ({ label, sx, ...rest }) => ( diff --git a/src/modules/manga/Manga.constants.ts b/src/modules/manga/Manga.constants.ts index 1c96d917..6271887d 100644 --- a/src/modules/manga/Manga.constants.ts +++ b/src/modules/manga/Manga.constants.ts @@ -8,7 +8,7 @@ import { MangaStatus } from '@/lib/graphql/generated/graphql.ts'; import { TranslationKey } from '@/Base.types.ts'; -import { MangaAction, MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { MangaAction, MangaIdInfo, MangaType } from '@/modules/manga/Manga.types.ts'; export const FALLBACK_MANGA: MangaIdInfo = { id: -1 }; @@ -101,3 +101,46 @@ export const actionToTranslationKey: { error: 'manga.action.track.add.label.error', }, }; + +export const SOURCES_BY_MANGA_TYPE: Record = { + [MangaType.MANGA]: [], + [MangaType.COMIC]: [], + [MangaType.WEBTOON]: ['mangatoon', 'manmanga', 'toomics', 'webcomics', 'webtoons', 'webtoon'], + [MangaType.MANHWA]: [ + 'hiperdex', + 'hmanhwa', + 'instamanhwa', + 'manhwa18', + 'manhwa68', + 'manhwa365', + 'manhwahentaime', + 'manhwamanga', + 'manhwatop', + 'manhwa', + 'manytoon', + 'manwha', + 'readmanhwa', + 'skymanga', + 'toonily', + 'webtoonxyz', + ], + [MangaType.MANHUA]: [ + '1st kiss manhua', + 'hero manhua', + 'manhuabox', + 'manhuaus', + 'manhuas', + 'manhuas', + 'readmanhua', + 'wuxiaworld', + 'manhua', + ], +}; + +export const MANGA_TAGS_BY_MANGA_TYPE: Record = { + [MangaType.MANGA]: ['manga'], + [MangaType.COMIC]: ['comic'], + [MangaType.WEBTOON]: ['webtoon', 'long strip'], + [MangaType.MANHWA]: ['manhwa'], + [MangaType.MANHUA]: ['manhua'], +}; diff --git a/src/modules/manga/Manga.types.ts b/src/modules/manga/Manga.types.ts index 3d063645..1ea42219 100644 --- a/src/modules/manga/Manga.types.ts +++ b/src/modules/manga/Manga.types.ts @@ -10,27 +10,36 @@ import { LongPressPointerHandlers, LongPressResult } from 'use-long-press/lib/us import { PopupState } from 'material-ui-popup-state/hooks'; import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSelectableCollection.ts'; import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx'; -import { ChapterType, MangaReaderFieldsFragment, MangaType, TrackRecordType } from '@/lib/graphql/generated/graphql.ts'; +import { + ChapterType, + MangaReaderFieldsFragment, + MangaType as MangaTypeGql, + Maybe, + SourceType, + TrackRecordType, +} from '@/lib/graphql/generated/graphql.ts'; import { SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx'; import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts'; import { GridLayout } from '@/modules/core/Core.types.ts'; export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate'; -type MangaCardBaseProps = Pick & +type MangaCardBaseProps = Pick & Omit & - Partial> & { + Partial> & { firstUnreadChapter?: Pick | null; }; -export type MangaIdInfo = Pick; -export type MangaChapterCountInfo = { chapters: Pick }; -export type MangaDownloadInfo = Pick & MangaChapterCountInfo; -export type MangaUnreadInfo = Pick & MangaChapterCountInfo; -export type MangaThumbnailInfo = Pick; +export type MangaIdInfo = Pick; +export type MangaChapterCountInfo = { chapters: Pick }; +export type MangaDownloadInfo = Pick & MangaChapterCountInfo; +export type MangaUnreadInfo = Pick & MangaChapterCountInfo; +export type MangaThumbnailInfo = Pick; export type MangaTrackRecordInfo = MangaIdInfo & { trackRecords: { nodes: Pick[] }; }; +export type MangaGenreInfo = Pick; +export type MangaSourceNameInfo = { source?: Maybe> }; export type MigrateMode = 'copy' | 'migrate'; @@ -41,7 +50,7 @@ export interface MangaCardProps { gridLayout?: GridLayout; inLibraryIndicator?: boolean; selected?: boolean | null; - handleSelection?: SelectableCollectionReturnType['handleSelection']; + handleSelection?: SelectableCollectionReturnType['handleSelection']; mode?: MangaCardMode; } @@ -69,3 +78,11 @@ export type MangaAction = | 'track'; export type TMangaReader = MangaReaderFieldsFragment; + +export enum MangaType { + MANGA, + COMIC, + WEBTOON, + MANHWA, + MANHUA, +} diff --git a/src/modules/manga/services/Mangas.ts b/src/modules/manga/services/Mangas.ts index c8955b4e..effcc9d7 100644 --- a/src/modules/manga/services/Mangas.ts +++ b/src/modules/manga/services/Mangas.ts @@ -28,12 +28,19 @@ import { MetadataMigrationSettings } from '@/modules/migration/Migration.types.t import { MangaAction, MangaDownloadInfo, + MangaGenreInfo, MangaIdInfo, + MangaSourceNameInfo, MangaThumbnailInfo, + MangaType, MangaUnreadInfo, MigrateMode, } from '@/modules/manga/Manga.types.ts'; -import { actionToTranslationKey } from '@/modules/manga/Manga.constants.ts'; +import { + actionToTranslationKey, + MANGA_TAGS_BY_MANGA_TYPE, + SOURCES_BY_MANGA_TYPE, +} from '@/modules/manga/Manga.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; type MangaToMigrate = NonNullable; @@ -524,4 +531,43 @@ export class Mangas { throw new Error(`Mangas::performAction: unknown action "${action}"`); } } + + static getType(manga: MangaGenreInfo & MangaSourceNameInfo): MangaType { + if (Mangas.isType(manga, MangaType.MANGA)) { + return MangaType.MANGA; + } + + if (Mangas.isType(manga, MangaType.COMIC)) { + return MangaType.COMIC; + } + + if (Mangas.isType(manga, MangaType.WEBTOON)) { + return MangaType.WEBTOON; + } + + if (Mangas.isType(manga, MangaType.MANHWA)) { + return MangaType.MANHWA; + } + + if (Mangas.isType(manga, MangaType.MANHUA)) { + return MangaType.MANHUA; + } + + return MangaType.MANGA; + } + + static isType(manga: MangaGenreInfo & MangaSourceNameInfo, type: MangaType): boolean { + return ( + manga.genre.some((genre) => MANGA_TAGS_BY_MANGA_TYPE[type].includes(genre.toLowerCase())) || + SOURCES_BY_MANGA_TYPE[type].includes(manga.source?.name.toLowerCase() ?? '') + ); + } + + static isLongStripType(manga: MangaGenreInfo & MangaSourceNameInfo): boolean { + return ( + Mangas.isType(manga, MangaType.WEBTOON) || + Mangas.isType(manga, MangaType.MANHWA) || + Mangas.isType(manga, MangaType.MANHUA) + ); + } } diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index 259c8d50..2afdca59 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -68,6 +68,7 @@ const APP_METADATA_OBJECT: Record = { pageGap: undefined, hotkeys: undefined, imagePreLoadAmount: undefined, + shouldUseAutoWebtoonMode: undefined, }; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); diff --git a/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx b/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx index 867fba41..601ec92a 100644 --- a/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx +++ b/src/modules/reader/components/settings/behaviour/ReaderBehaviourSettings.tsx @@ -8,6 +8,8 @@ import Stack from '@mui/material/Stack'; import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { IReaderSettingsWithDefaultFlag, ReaderSettingsTypeProps } from '@/modules/reader/types/Reader.types.ts'; import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; @@ -46,6 +48,18 @@ export const ReaderBehaviourSettings = ({ onChange={(_, checked) => updateSetting('shouldOffsetDoubleSpreads', checked)} /> )} + + {t('reader.settings.auto_webtoon_mode.title')} + + {t('reader.settings.auto_webtoon_mode.description')} + + + } + checked={settings.shouldUseAutoWebtoonMode} + onChange={(_, checked) => updateSetting('shouldUseAutoWebtoonMode', checked)} + /> = { diff --git a/src/modules/reader/screens/Reader.tsx b/src/modules/reader/screens/Reader.tsx index b425930f..defb34bc 100644 --- a/src/modules/reader/screens/Reader.tsx +++ b/src/modules/reader/screens/Reader.tsx @@ -42,6 +42,7 @@ import { ReaderResumeMode, ReaderStateChapters, ReaderTransitionPageMode, + ReadingMode, TReaderStateMangaContext, TReaderStateSettingsContext, } from '@/modules/reader/types/Reader.types.ts'; @@ -52,7 +53,7 @@ import { TReaderOverlayContext } from '@/modules/reader/types/ReaderOverlay.type import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts'; import { withPropsFrom } from '@/modules/core/hoc/withPropsFrom.tsx'; import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; -import { FALLBACK_MANGA } from '@/modules/manga/Manga.constants.ts'; +import { isAutoWebtoonMode } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; const BaseReader = ({ setTitle, @@ -221,25 +222,36 @@ const BaseReader = ({ // set settings state useEffect(() => { - if (!mangaResponse.data?.manga || defaultSettingsResponse.loading || defaultSettingsResponse.error) { + const mangaFromResponse = mangaResponse.data?.manga; + if (!mangaFromResponse || defaultSettingsResponse.loading || defaultSettingsResponse.error) { return; } - const settingsWithDefaultProfileFallback = getReaderSettingsFor( - mangaResponse.data?.manga ?? FALLBACK_MANGA, - defaultSettings, + const settingsWithDefaultProfileFallback = getReaderSettingsFor(mangaFromResponse, defaultSettings); + + const shouldUseWebtoonMode = isAutoWebtoonMode( + mangaFromResponse, + settingsWithDefaultProfileFallback.shouldUseAutoWebtoonMode, + settingsWithDefaultProfileFallback.readingMode, ); - const profile = settingsWithDefaultProfileFallback.readingMode.value; + const defaultSettingsWithAutoReadingMode = { + ...defaultSettings, + readingMode: shouldUseWebtoonMode ? ReadingMode.WEBTOON : defaultSettings.readingMode, + }; + + const profile = shouldUseWebtoonMode + ? ReadingMode.WEBTOON + : settingsWithDefaultProfileFallback.readingMode.value; const profileSettings = getReaderSettings( 'global', { meta: defaultSettingsMetadata! }, - defaultSettings, + defaultSettingsWithAutoReadingMode, undefined, profile, ); - setSettings(getReaderSettingsFor(mangaResponse.data!.manga, profileSettings)); + setSettings(getReaderSettingsFor(mangaFromResponse, profileSettings)); }, [mangaResponse.data?.manga, defaultSettings]); // set chapters state diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index 6249286d..fc7f4ba9 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -123,6 +123,7 @@ export interface IReaderSettingsGlobal { backgroundColor: ReaderBackgroundColor; hotkeys: Record; imagePreLoadAmount: number; + shouldUseAutoWebtoonMode: boolean; } export interface IReaderSettingsManga { diff --git a/src/modules/reader/utils/ReaderSettings.utils.tsx b/src/modules/reader/utils/ReaderSettings.utils.tsx index 5a8c8d28..99d09015 100644 --- a/src/modules/reader/utils/ReaderSettings.utils.tsx +++ b/src/modules/reader/utils/ReaderSettings.utils.tsx @@ -6,7 +6,14 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { IReaderSettings, ReaderPageScaleMode, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { + IReaderSettings, + IReaderSettingsWithDefaultFlag, + ReaderPageScaleMode, + ReadingMode, +} from '@/modules/reader/types/Reader.types.ts'; +import { MangaGenreInfo, MangaSourceNameInfo } from '@/modules/manga/Manga.types.ts'; +import { Mangas } from '@/modules/manga/services/Mangas.ts'; export const isOffsetDoubleSpreadPagesEditable = (readingMode: IReaderSettings['readingMode']): boolean => readingMode === ReadingMode.DOUBLE_PAGE; @@ -19,3 +26,9 @@ export const isContinuousReadingMode = (readingMode: IReaderSettings['readingMod export const isContinuousVerticalReadingMode = (readingMode: IReaderSettings['readingMode']): boolean => [ReadingMode.CONTINUOUS_VERTICAL, ReadingMode.WEBTOON].includes(readingMode); + +export const isAutoWebtoonMode = ( + manga: MangaGenreInfo & MangaSourceNameInfo, + shouldUseAutoWebtoonMode: IReaderSettings['shouldUseAutoWebtoonMode'], + readingMode: IReaderSettingsWithDefaultFlag['readingMode'], +): boolean => shouldUseAutoWebtoonMode && readingMode.isDefault && Mangas.isLongStripType(manga);