diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 1ffd5fbe..acb74d19 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -279,7 +279,7 @@ class _AnimeStreamPageState extends riv.ConsumerState final ValueNotifier _playbackSpeed = ValueNotifier(1.0); final ValueNotifier _isDoubleSpeed = ValueNotifier(false); late final ValueNotifier _currentPosition = ValueNotifier( - _streamController.geTCurrentPosition(), + _streamController.getCurrentPosition(), ); final ValueNotifier _currentTotalDuration = ValueNotifier(null); final ValueNotifier _showFitLabel = ValueNotifier(false); @@ -899,11 +899,11 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _completed; _currentTotalDurationSub; _loadAndroidFont().then((_) { - _openMedia(_video.value!, _streamController.geTCurrentPosition()); + _openMedia(_video.value!, _streamController.getCurrentPosition()); if (widget.isTorrent) { Future.delayed(const Duration(seconds: 10)).then((_) { if (mounted) { - _openMedia(_video.value!, _streamController.geTCurrentPosition()); + _openMedia(_video.value!, _streamController.getCurrentPosition()); } }); } @@ -1020,8 +1020,8 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _currentTotalDuration.value, save: save, ); - _streamController.setAnimeHistoryUpdate( - watchTimeSeconds: saveWatchTime ? _watchStopwatch.elapsed.inSeconds : 0, + _streamController.setHistoryUpdate( + elapsedSeconds: saveWatchTime ? _watchStopwatch.elapsed.inSeconds : 0, ); } diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index a4694285..23bdc76f 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -2,11 +2,10 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:isar_community/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; -import 'package:mangayomi/models/history.dart'; -import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/track.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; +import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:mangayomi/modules/more/settings/player/providers/player_state_provider.dart'; import 'package:mangayomi/services/aniskip.dart'; import 'package:mangayomi/utils/chapter_recognition.dart'; @@ -17,7 +16,8 @@ part 'anime_player_controller_provider.g.dart'; final fullscreenProvider = StateProvider(() => false); @riverpod -class AnimeStreamController extends _$AnimeStreamController { +class AnimeStreamController extends _$AnimeStreamController + with ChapterControllerMixin { @override KeepAliveLink build({required Chapter episode}) { _keepAliveLink = ref.keepAlive(); @@ -25,101 +25,35 @@ class AnimeStreamController extends _$AnimeStreamController { } KeepAliveLink? _keepAliveLink; - KeepAliveLink? get keepAliveLink => _keepAliveLink; - Manga getAnime() { - return episode.manga.value!; - } - final incognitoMode = isar.settings.getSync(227)!.incognitoMode!; + // Bridge the mixin's `chapter` contract to the `episode` build parameter. + @override + Chapter get chapter => episode; - Settings getIsarSetting() { - return isar.settings.getSync(227)!; - } + // Keep incognitoMode as a final field (read once, not on every access). + @override + final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!; - (int, bool) getEpisodeIndex() { - final episodes = getAnime().getFilteredChapterList(); - int? index; - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i; - } - } - if (index == null) { - final episodes = getAnime().chapters.toList().reversed.toList(); - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i; - } - } - return (index!, false); - } - return (index, true); - } + // --------------------------------------------------------------------------- + // Anime-flavoured aliases (preserve the existing public API) + // --------------------------------------------------------------------------- - (int, bool) getPrevEpisodeIndex() { - final episodes = getAnime().getFilteredChapterList(); - int? index; - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i + 1; - } - } - if (index == null) { - final episodes = getAnime().chapters.toList().reversed.toList(); - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i + 1; - } - } - return (index!, false); - } - return (index, true); - } + (int, bool) getEpisodeIndex() => getChapterIndex(); - (int, bool) getNextEpisodeIndex() { - final episodes = getAnime().getFilteredChapterList(); - int? index; - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i - 1; - } - } - if (index == null) { - final episodes = getAnime().chapters.toList().reversed.toList(); - for (var i = 0; i < episodes.length; i++) { - if (episodes[i].id == episode.id) { - index = i - 1; - } - } - return (index!, false); - } - return (index, true); - } + Chapter getPrevEpisode() => getPrevChapter(); + Chapter getNextEpisode() => getNextChapter(); - Chapter getPrevEpisode() { - final prevEpIdx = getPrevEpisodeIndex(); - return prevEpIdx.$2 - ? getAnime().getFilteredChapterList()[prevEpIdx.$1] - : getAnime().chapters.toList().reversed.toList()[prevEpIdx.$1]; - } + int getEpisodesLength(bool isInFilterList) => + getChaptersLength(isInFilterList); - Chapter getNextEpisode() { - final nextEpIdx = getNextEpisodeIndex(); - return nextEpIdx.$2 - ? getAnime().getFilteredChapterList()[nextEpIdx.$1] - : getAnime().chapters.toList().reversed.toList()[nextEpIdx.$1]; - } + // --------------------------------------------------------------------------- + // Playback position + // --------------------------------------------------------------------------- - int getEpisodesLength(bool isInFilterList) { - return isInFilterList - ? getAnime().getFilteredChapterList().length - : getAnime().chapters.length; - } - - Duration geTCurrentPosition() { + Duration getCurrentPosition() { if (incognitoMode) return Duration.zero; - String position = episode.lastPageRead ?? "0"; + final position = episode.lastPageRead ?? '0'; return Duration( milliseconds: episode.isRead! ? 0 @@ -127,50 +61,6 @@ class AnimeStreamController extends _$AnimeStreamController { ); } - void setAnimeHistoryUpdate({int watchTimeSeconds = 0}) { - if (incognitoMode) return; - isar.writeTxnSync(() { - Manga? anime = episode.manga.value; - anime!.lastRead = DateTime.now().millisecondsSinceEpoch; - anime.updatedAt = DateTime.now().millisecondsSinceEpoch; - isar.mangas.putSync(anime); - }); - History? history; - - final empty = isar.historys - .filter() - .mangaIdEqualTo(getAnime().id) - .isEmptySync(); - - if (empty) { - history = History( - mangaId: getAnime().id, - date: DateTime.now().millisecondsSinceEpoch.toString(), - itemType: getAnime().itemType, - chapterId: episode.id, - updatedAt: DateTime.now().millisecondsSinceEpoch, - )..chapter.value = episode; - } else { - history = - (isar.historys - .filter() - .mangaIdEqualTo(getAnime().id) - .findFirstSync())! - ..chapterId = episode.id - ..chapter.value = episode - ..date = DateTime.now().millisecondsSinceEpoch.toString() - ..updatedAt = DateTime.now().millisecondsSinceEpoch; - } - isar.writeTxnSync(() { - if (watchTimeSeconds > 0) { - history!.readingTimeSeconds = - (history.readingTimeSeconds ?? 0) + watchTimeSeconds; - } - isar.historys.putSync(history!); - history.chapter.saveSync(); - }); - } - void setCurrentPosition( Duration duration, Duration? totalDuration, { @@ -200,6 +90,10 @@ class AnimeStreamController extends _$AnimeStreamController { } } + // --------------------------------------------------------------------------- + // AniSkip + // --------------------------------------------------------------------------- + (int, int)? _getTrackId() { final malId = isar.tracks .filter() diff --git a/lib/modules/browse/global_search/global_search_screen.dart b/lib/modules/browse/global_search/global_search_screen.dart index c6da3c57..e274f532 100644 --- a/lib/modules/browse/global_search/global_search_screen.dart +++ b/lib/modules/browse/global_search/global_search_screen.dart @@ -11,7 +11,7 @@ import 'package:mangayomi/modules/manga/home/manga_home_screen.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/router/router.dart'; import 'package:mangayomi/models/source.dart'; -import 'package:mangayomi/services/search_.dart'; +import 'package:mangayomi/services/search.dart'; import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/constant.dart'; @@ -156,7 +156,7 @@ class _SourceSearchScreenState extends ConsumerState { source: widget.source, page: 1, query: widget.query, - filterList: [], + filterList: const [], ).future, ); if (mounted) { diff --git a/lib/modules/library/providers/library_state_provider.dart b/lib/modules/library/providers/library_state_provider.dart index 8f739452..7c5c64dd 100644 --- a/lib/modules/library/providers/library_state_provider.dart +++ b/lib/modules/library/providers/library_state_provider.dart @@ -4,7 +4,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'library_state_provider.g.dart'; diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 93df9b56..5bc42e45 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -22,7 +22,7 @@ import 'package:mangayomi/modules/library/providers/local_archive.dart'; import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart'; import 'package:mangayomi/modules/manga/detail/widgets/tracker_search_widget.dart'; import 'package:mangayomi/modules/manga/detail/widgets/tracker_widget.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart'; import 'package:mangayomi/modules/more/settings/track/widgets/track_listile.dart'; diff --git a/lib/modules/manga/detail/widgets/migrate_screen.dart b/lib/modules/manga/detail/widgets/migrate_screen.dart index 947e7813..e9b1ef58 100644 --- a/lib/modules/manga/detail/widgets/migrate_screen.dart +++ b/lib/modules/manga/detail/widgets/migrate_screen.dart @@ -17,7 +17,7 @@ import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_pr import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/models/source.dart'; import 'package:mangayomi/services/get_detail.dart'; -import 'package:mangayomi/services/search_.dart'; +import 'package:mangayomi/services/search.dart'; import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/date.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; @@ -173,7 +173,7 @@ class _MigrationSourceSearchScreenState source: widget.source, page: 1, query: widget.query, - filterList: [], + filterList: const [], ).future, ); if (mounted) { diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart new file mode 100644 index 00000000..af1100a9 --- /dev/null +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -0,0 +1,135 @@ +import 'package:isar_community/isar.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/history.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/utils/extensions/manga.dart'; + +/// Shared navigation and history logic used by [ReaderController], +/// [NovelReaderController], and [AnimeStreamController]. +/// +/// Concrete classes must satisfy the single abstract member [chapter]. +/// The Riverpod-generated base class already exposes the build parameter as a +/// getter, so no extra boilerplate is needed in normal cases. +/// +/// [incognitoMode] and [getIsarSetting] are concrete in the mixin but can be +/// overridden — [ReaderController] overrides [getIsarSetting] to add caching. +mixin ChapterControllerMixin { + // --------------------------------------------------------------------------- + // Contract – provided by the Riverpod-generated superclass + // --------------------------------------------------------------------------- + + /// The current chapter or episode. + Chapter get chapter; + + // --------------------------------------------------------------------------- + // Basic helpers + // --------------------------------------------------------------------------- + + Manga getManga() => chapter.manga.value!; + + // Declared as a getter so concrete classes can override with a `final` field + // (which is more efficient since incognito status never changes mid-session). + bool get incognitoMode => isar.settings.getSync(227)!.incognitoMode!; + + Settings getIsarSetting() => isar.settings.getSync(227)!; + + String getMangaName() => getManga().name!; + String getSourceName() => getManga().source!; + String getChapterTitle() => chapter.name!; + + // --------------------------------------------------------------------------- + // Chapter / episode navigation + // --------------------------------------------------------------------------- + + (int, bool) getChapterIndex() => _chapterIndexWithOffset(0); + Chapter getPrevChapter() => _chapterWithOffset(1); + Chapter getNextChapter() => _chapterWithOffset(-1); + + /// Finds this [chapter] in either the filtered list or the raw list and + /// returns [index + offset]. The boolean indicates whether the filtered list + /// was used (true) or the full list (false). + (int, bool) _chapterIndexWithOffset(int offset) { + final manga = getManga(); + + int? findIn(List list) { + for (var i = 0; i < list.length; i++) { + if (list[i].id == chapter.id) return i + offset; + } + return null; + } + + final index = findIn(manga.getChapterListForReading()); + if (index != null) return (index, true); + // Fallback to raw list if chapter was filtered out. + final all = manga.chapters.toList(); + return (findIn(all)!, false); + } + + Chapter _chapterWithOffset(int offset) { + final idx = _chapterIndexWithOffset(offset); + final list = idx.$2 + ? getManga().getChapterListForReading() + : getManga().chapters.toList(); + if (idx.$1 < 0 || idx.$1 >= list.length) { + throw RangeError('No chapter at offset $offset from ${chapter.id}'); + } + return list[idx.$1]; + } + + int getChaptersLength(bool isInFilterList) => isInFilterList + ? getManga().getChapterListForReading().length + : getManga().chapters.length; + + // --------------------------------------------------------------------------- + // History + // --------------------------------------------------------------------------- + + /// Writes a history entry for the current chapter/episode and bumps the + /// parent manga/anime's [lastRead] timestamp. + /// + /// [elapsedSeconds] accumulates watch/reading time; pass 0 to skip that + /// field (the caller is responsible for tracking wall-clock deltas). + void setHistoryUpdate({int elapsedSeconds = 0}) { + if (incognitoMode) return; + final manga = getManga(); + + isar.writeTxnSync(() { + final m = chapter.manga.value!; + m.lastRead = DateTime.now().millisecondsSinceEpoch; + m.updatedAt = DateTime.now().millisecondsSinceEpoch; + isar.mangas.putSync(m); + }); + + final isEmpty = isar.historys + .filter() + .mangaIdEqualTo(manga.id) + .isEmptySync(); + + final History history; + if (isEmpty) { + history = History( + mangaId: manga.id, + date: DateTime.now().millisecondsSinceEpoch.toString(), + itemType: manga.itemType, + chapterId: chapter.id, + )..chapter.value = chapter; + } else { + history = isar.historys.filter().mangaIdEqualTo(manga.id).findFirstSync()! + ..chapterId = chapter.id + ..chapter.value = chapter + ..date = DateTime.now().millisecondsSinceEpoch.toString(); + } + + isar.writeTxnSync(() { + history.updatedAt = DateTime.now().millisecondsSinceEpoch; + if (elapsedSeconds > 0) { + history.readingTimeSeconds = + (history.readingTimeSeconds ?? 0) + elapsedSeconds; + } + isar.historys.putSync(history); + history.chapter.saveSync(); + }); + } +} diff --git a/lib/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart b/lib/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart new file mode 100644 index 00000000..82946f93 --- /dev/null +++ b/lib/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; + +/// Shared reader-specific settings and actions for a [Chapter]. +/// +/// This mixin builds on top of [ChapterControllerMixin] and provides: +/// - bookmark toggling +/// - auto-scroll preferences +/// +/// It is intended for reader-like controllers (manga/novel), not anime. +/// +/// Classes using this mixin may override [onSettingsMutated] to react to +/// settings changes (e.g. invalidate caches). +mixin ChapterReaderSettingsMixin on ChapterControllerMixin { + // --------------------------------------------------------------------------- + // Hooks + // --------------------------------------------------------------------------- + + /// Called after any settings mutation (e.g. [setAutoScroll], [setReaderMode], + /// [setPageMode], [setShowPageNumber], [setPageIndex]). + /// + /// Default is a no-op. Controllers can override this to invalidate caches + /// or trigger updates when settings change. + @protected + void onSettingsMutated() {} + + // --------------------------------------------------------------------------- + // Bookmarks + // --------------------------------------------------------------------------- + + /// Toggles the bookmark state of the current [chapter]. + /// + /// Updates the persisted chapter and bumps its [updatedAt] timestamp. + /// No-op in incognito mode. + void setChapterBookmarked() { + if (incognitoMode) return; + final isBookmarked = getChapterBookmarked(); + final chap = chapter; + isar.writeTxnSync(() { + chap.isBookmarked = !isBookmarked; + chap.updatedAt = DateTime.now().millisecondsSinceEpoch; + isar.chapters.putSync(chap); + }); + } + + /// Returns whether the current [chapter] is bookmarked. + /// + /// Reads directly from the database to ensure consistency. + bool getChapterBookmarked() { + return isar.chapters.getSync(chapter.id!)!.isBookmarked!; + } + + // --------------------------------------------------------------------------- + // Auto-scroll + // --------------------------------------------------------------------------- + + /// Returns the auto-scroll configuration for the current manga. + /// + /// The tuple contains: + /// - whether auto-scroll is enabled + /// - the page offset (scroll speed / distance) + /// + /// Falls back to `(false, 10)` if no custom setting exists. + (bool, double) autoScrollValues() { + final autoScrollPagesList = getIsarSetting().autoScrollPages ?? []; + final autoScrollPages = autoScrollPagesList.where( + (element) => element.mangaId == getManga().id, + ); + if (autoScrollPages.isNotEmpty) { + return ( + autoScrollPages.first.autoScroll ?? false, + autoScrollPages.first.pageOffset ?? 10, + ); + } + return (false, 10); + } + + /// Persists auto-scroll settings for the current manga. + /// + /// Replaces any existing entry for this manga with the new values and updates + /// the global settings object. Calls [onSettingsMutated] afterwards so + /// controllers can react (e.g. invalidate cached settings). + void setAutoScroll(bool value, double offset) { + List? autoScrollPagesList = []; + for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) { + if (autoScrollPages.mangaId != getManga().id) { + autoScrollPagesList.add(autoScrollPages); + } + } + autoScrollPagesList.add( + AutoScrollPages() + ..mangaId = getManga().id + ..pageOffset = offset + ..autoScroll = value, + ); + isar.writeTxnSync( + () => isar.settings.putSync( + getIsarSetting() + ..autoScrollPages = autoScrollPagesList + ..updatedAt = DateTime.now().millisecondsSinceEpoch, + ), + ); + onSettingsMutated(); + } +} diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 1869c9e8..01900c29 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -1,20 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; -import 'package:isar_community/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; -import 'package:mangayomi/models/download.dart'; -import 'package:mangayomi/models/history.dart'; -import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; import 'package:mangayomi/models/settings.dart'; -import 'package:mangayomi/models/track.dart'; -import 'package:mangayomi/models/track_preference.dart'; -import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart'; -import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart'; -import 'package:mangayomi/utils/chapter_recognition.dart'; import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'reader_controller_provider.g.dart'; @@ -47,7 +39,8 @@ BoxFit getBoxFit(ScaleType scaleType) { } @riverpod -class ReaderController extends _$ReaderController { +class ReaderController extends _$ReaderController + with ChapterControllerMixin, ChapterReaderSettingsMixin { @override KeepAliveLink build({required Chapter chapter}) { _keepAliveLink = ref.keepAlive(); @@ -55,20 +48,24 @@ class ReaderController extends _$ReaderController { } KeepAliveLink? _keepAliveLink; - KeepAliveLink? get keepAliveLink => _keepAliveLink; - Manga getManga() { - return chapter.manga.value!; - } + // Keep incognitoMode as a final field so it is read from Isar only once. + @override + final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!; - Chapter geChapter() { - return chapter; - } - - final incognitoMode = isar.settings.getSync(227)!.incognitoMode!; + // Override getIsarSetting to add per-instance caching; callers that mutate + // settings must call _invalidateSettingsCache() afterwards. Settings? _cachedSettings; - void _invalidateSettingsCache() => _cachedSettings = null; + @override + void onSettingsMutated() => _cachedSettings = null; + + @override + Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!; + + // --------------------------------------------------------------------------- + // Reader-specific settings + // --------------------------------------------------------------------------- ReaderMode getReaderMode() { final personalReaderModeList = @@ -79,44 +76,7 @@ class ReaderController extends _$ReaderController { if (personalReaderMode.isNotEmpty) { return personalReaderMode.first.readerMode; } - return isar.settings.getSync(227)!.defaultReaderMode; - } - - (bool, double) autoScrollValues() { - final autoScrollPagesList = getIsarSetting().autoScrollPages ?? []; - final autoScrollPages = autoScrollPagesList.where( - (element) => element.mangaId == getManga().id, - ); - if (autoScrollPages.isNotEmpty) { - return ( - autoScrollPages.first.autoScroll ?? false, - autoScrollPages.first.pageOffset ?? 10, - ); - } - return (false, 10); - } - - void setAutoScroll(bool value, double offset) { - List? autoScrollPagesList = []; - for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) { - if (autoScrollPages.mangaId != getManga().id) { - autoScrollPagesList.add(autoScrollPages); - } - } - autoScrollPagesList.add( - AutoScrollPages() - ..mangaId = getManga().id - ..pageOffset = offset - ..autoScroll = value, - ); - isar.writeTxnSync( - () => isar.settings.putSync( - getIsarSetting() - ..autoScrollPages = autoScrollPagesList - ..updatedAt = DateTime.now().millisecondsSinceEpoch, - ), - ); - _invalidateSettingsCache(); + return getIsarSetting().defaultReaderMode; } PageMode getPageMode() { @@ -150,7 +110,7 @@ class ReaderController extends _$ReaderController { ..updatedAt = DateTime.now().millisecondsSinceEpoch, ), ); - _invalidateSettingsCache(); + onSettingsMutated(); } void setPageMode(PageMode newPageMode) { @@ -172,7 +132,7 @@ class ReaderController extends _$ReaderController { ..updatedAt = DateTime.now().millisecondsSinceEpoch, ), ); - _invalidateSettingsCache(); + onSettingsMutated(); } void setShowPageNumber(bool value) { @@ -184,154 +144,18 @@ class ReaderController extends _$ReaderController { ..updatedAt = DateTime.now().millisecondsSinceEpoch, ), ); - _invalidateSettingsCache(); + onSettingsMutated(); } } - Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!; - bool getShowPageNumber() { if (!incognitoMode) return getIsarSetting().showPagesNumber!; return true; } - void setMangaHistoryUpdate({int readingTimeSeconds = 0}) { - if (incognitoMode) return; - isar.writeTxnSync(() { - Manga? manga = chapter.manga.value; - manga!.lastRead = DateTime.now().millisecondsSinceEpoch; - manga.updatedAt = DateTime.now().millisecondsSinceEpoch; - isar.mangas.putSync(manga); - }); - History? history; - - final empty = isar.historys - .filter() - .mangaIdEqualTo(getManga().id) - .isEmptySync(); - - if (empty) { - history = History( - mangaId: getManga().id, - date: DateTime.now().millisecondsSinceEpoch.toString(), - itemType: getManga().itemType, - chapterId: chapter.id, - )..chapter.value = chapter; - } else { - history = - (isar.historys - .filter() - .mangaIdEqualTo(getManga().id) - .findFirstSync())! - ..chapterId = chapter.id - ..chapter.value = chapter - ..date = DateTime.now().millisecondsSinceEpoch.toString(); - } - isar.writeTxnSync(() { - history!.updatedAt = DateTime.now().millisecondsSinceEpoch; - if (readingTimeSeconds > 0) { - history.readingTimeSeconds = - (history.readingTimeSeconds ?? 0) + readingTimeSeconds; - } - isar.historys.putSync(history); - history.chapter.saveSync(); - }); - } - - void setChapterBookmarked() { - if (incognitoMode) return; - final isBookmarked = getChapterBookmarked(); - final chap = chapter; - isar.writeTxnSync(() { - chap.isBookmarked = !isBookmarked; - chap.updatedAt = DateTime.now().millisecondsSinceEpoch; - isar.chapters.putSync(chap); - }); - } - - bool getChapterBookmarked() { - return isar.chapters.getSync(chapter.id!)!.isBookmarked!; - } - - (int, bool) getPrevChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i + 1; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().reversed.toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i + 1; - } - } - return (index!, false); - } - return (index, true); - } - - (int, bool) getNextChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i - 1; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().reversed.toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i - 1; - } - } - return (index!, false); - } - return (index, true); - } - - (int, bool) getChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().reversed.toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i; - } - } - return (index!, false); - } - return (index, true); - } - - Chapter getPrevChapter() { - final prevChapIdx = getPrevChapterIndex(); - return prevChapIdx.$2 - ? getManga().getFilteredChapterList()[prevChapIdx.$1] - : getManga().chapters.toList().reversed.toList()[prevChapIdx.$1]; - } - - Chapter getNextChapter() { - final nextChapIdx = getNextChapterIndex(); - return nextChapIdx.$2 - ? getManga().getFilteredChapterList()[nextChapIdx.$1] - : getManga().chapters.toList().reversed.toList()[nextChapIdx.$1]; - } - - int getChaptersLength(bool isInFilterList) { - return isInFilterList - ? getManga().getFilteredChapterList().length - : getManga().chapters.length; - } + // --------------------------------------------------------------------------- + // Page tracking + // --------------------------------------------------------------------------- int getPageIndex() { if (incognitoMode) return 0; @@ -392,7 +216,7 @@ class ReaderController extends _$ReaderController { chap.updatedAt = DateTime.now().millisecondsSinceEpoch; isar.chapters.putSync(chap); }); - _invalidateSettingsCache(); + onSettingsMutated(); if (isRead) { chapter.updateTrackChapterRead(ref); if (ref.read(deleteDownloadAfterReadingStateProvider)) { @@ -401,179 +225,4 @@ class ReaderController extends _$ReaderController { } } } - - String getMangaName() { - return getManga().name!; - } - - String getSourceName() { - return getManga().source!; - } - - String getChapterTitle() { - return chapter.name!; - } -} - -extension ChapterExtensions on Chapter { - void updateTrackChapterRead(dynamic ref) { - if (!(ref is WidgetRef || ref is Ref)) return; - final updateProgressAfterReading = ref.watch( - updateProgressAfterReadingStateProvider, - ); - if (!updateProgressAfterReading) return; - final manga = this.manga.value!; - final chapterNumber = ChapterRecognition().parseChapterNumber( - manga.name!, - name!, - ); - - final tracks = isar.tracks - .filter() - .idIsNotNull() - .itemTypeEqualTo(manga.itemType) - .mangaIdEqualTo(manga.id!) - .findAllSync(); - - if (tracks.isEmpty) return; - for (var track in tracks) { - final service = isar.trackPreferences - .filter() - .syncIdIsNotNull() - .syncIdEqualTo(track.syncId) - .findFirstSync(); - if (!(service == null || chapterNumber <= (track.lastChapterRead ?? 0))) { - if (track.status != TrackStatus.completed) { - track.lastChapterRead = chapterNumber; - if (track.lastChapterRead == track.totalChapter && - (track.totalChapter ?? 0) > 0) { - track.status = TrackStatus.completed; - track.finishedReadingDate = DateTime.now().millisecondsSinceEpoch; - } else { - track.status = manga.itemType == ItemType.manga - ? TrackStatus.reading - : TrackStatus.watching; - if (track.lastChapterRead == 1) { - track.startedReadingDate = DateTime.now().millisecondsSinceEpoch; - } - } - } - ref - .read( - trackStateProvider( - track: track, - itemType: manga.itemType, - widgetRef: ref, - ).notifier, - ) - .updateManga(); - } - } - } -} - -extension MangaExtensions on Manga { - List getFilteredChapterList() { - final data = this.chapters.toList().reversed.toList(); - final settings = isar.settings.getSync(227)!; - final filterUnread = - (settings.chapterFilterUnreadList! - .where((element) => element.mangaId == id) - .toList() - .firstOrNull ?? - ChapterFilterUnread(mangaId: id, type: 0)) - .type!; - - final filterBookmarked = - (settings.chapterFilterBookmarkedList! - .where((element) => element.mangaId == id) - .toList() - .firstOrNull ?? - ChapterFilterBookmarked(mangaId: id, type: 0)) - .type!; - final filterDownloaded = - (settings.chapterFilterDownloadedList! - .where((element) => element.mangaId == id) - .toList() - .firstOrNull ?? - ChapterFilterDownloaded(mangaId: id, type: 0)) - .type!; - - final sortChapter = - (settings.sortChapterList! - .where((element) => element.mangaId == id) - .toList() - .firstOrNull ?? - SortChapter(mangaId: id, index: 1, reverse: false)) - .index; - final filterScanlator = _getFilterScanlator(this) ?? []; - final chapterIds = data.map((c) => c.id).whereType().toList(); - final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty) - ? const {} - : isar.downloads - .filter() - .anyOf(chapterIds, (q, id) => q.idEqualTo(id)) - .isDownloadEqualTo(true) - .findAllSync() - .map((d) => d.id!) - .toSet(); - List? chapterList; - chapterList = data - .where( - (element) => filterUnread == 1 - ? element.isRead == false - : filterUnread == 2 - ? element.isRead == true - : true, - ) - .where( - (element) => filterBookmarked == 1 - ? element.isBookmarked == true - : filterBookmarked == 2 - ? element.isBookmarked == false - : true, - ) - .where((element) { - if (filterDownloaded == 0) return true; - final isDownloaded = downloadedIds.contains(element.id); - return filterDownloaded == 1 ? isDownloaded : !isDownloaded; - }) - .where((element) => !filterScanlator.contains(element.scanlator)) - .toList(); - List chapters = sortChapter == 0 - ? chapterList.reversed.toList() - : chapterList; - if (sortChapter == 0) { - chapters.sort((a, b) { - return (a.scanlator == null || - b.scanlator == null || - a.dateUpload == null || - b.dateUpload == null) - ? 0 - : a.scanlator!.compareTo(b.scanlator!) | - a.dateUpload!.compareTo(b.dateUpload!); - }); - } else if (sortChapter == 2) { - chapters.sort((a, b) { - return (a.dateUpload == null || b.dateUpload == null) - ? 0 - : int.parse(a.dateUpload!).compareTo(int.parse(b.dateUpload!)); - }); - } else if (sortChapter == 3) { - chapters.sort((a, b) { - return (a.name == null || b.name == null) - ? 0 - : a.name!.compareTo(b.name!); - }); - } - return chapters; - } -} - -List? _getFilterScanlator(Manga manga) { - final scanlators = isar.settings.getSync(227)!.filterScanlatorList ?? []; - final filter = scanlators - .where((element) => element.mangaId == manga.id) - .toList(); - return filter.firstOrNull?.scanlators; } diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 20ca0d8c..69d65b44 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -24,6 +24,7 @@ import 'package:mangayomi/modules/manga/reader/widgets/page_indicator.dart'; import 'package:mangayomi/modules/manga/reader/widgets/image_actions_dialog.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/others.dart'; import 'package:mangayomi/utils/riverpod.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; import 'package:mangayomi/services/get_chapter_pages.dart'; @@ -154,7 +155,6 @@ class _MangaChapterPageGalleryState bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; final ValueNotifier _isScrolling = ValueNotifier(false); Timer? _scrollIdleTimer; - bool _firstLaunch = true; final Stopwatch _readingStopwatch = Stopwatch(); /// Flag to prevent fullscreen from being disabled when navigating between @@ -166,8 +166,8 @@ class _MangaChapterPageGalleryState void dispose() { WidgetsBinding.instance.removeObserver(this); _readingStopwatch.stop(); - _readerController.setMangaHistoryUpdate( - readingTimeSeconds: _readingStopwatch.elapsed.inSeconds, + _readerController.setHistoryUpdate( + elapsedSeconds: _readingStopwatch.elapsed.inSeconds, ); _rebuildDetail.close(); _doubleClickAnimationController.dispose(); @@ -176,6 +176,7 @@ class _MangaChapterPageGalleryState _autoScroll.value = false; _autoScroll.dispose(); _autoScrollPage.dispose(); + _currentPageDisplayIndex.dispose(); _scrollIdleTimer?.cancel(); _isScrolling.dispose(); _itemPositionsListener.itemPositions.removeListener(_readProgressListener); @@ -194,11 +195,11 @@ class _MangaChapterPageGalleryState ); } discordRpc?.showIdleText(); - final actualIdx = _pageViewToActualIndex(_currentIndex!); + final actualIdx = _pageViewToActualIndexSync(_currentIndex!); final index = pages[actualIdx].index; if (index != null) { _readerController.setPageIndex( - _isDoublePageActive ? index : _geCurrentIndex(index), + _isDoublePageActiveSync ? index : _geCurrentIndex(index), true, ); } @@ -237,6 +238,9 @@ class _MangaChapterPageGalleryState final _failedToLoadImage = ValueNotifier(false); late int? _currentIndex = _readerController.getPageIndex(); + late final ValueNotifier _currentPageDisplayIndex = ValueNotifier( + _readerController.getPageIndex(), + ); late final ItemScrollController _itemScrollController = ItemScrollController(); @@ -300,6 +304,9 @@ class _MangaChapterPageGalleryState final _currentReaderMode = StateProvider(() => null); PageMode? _pageMode; bool _isView = false; + + /// Cached reader mode to safely access in dispose without ref.read() + ReaderMode? _cachedReaderMode; Alignment _scalePosition = Alignment.center; final PhotoViewController _photoViewController = PhotoViewController(); final PhotoViewScaleStateController _photoViewScaleStateController = @@ -346,6 +353,9 @@ class _MangaChapterPageGalleryState @override Widget build(BuildContext context) { + final animatePageTransitions = ref.watch( + animatePageTransitionsStateProvider, + ); final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); final readerMode = ref.watch(_currentReaderMode); @@ -358,13 +368,13 @@ class _MangaChapterPageGalleryState onPreviousPage: () => navigationService.previousPage( readerMode: readerMode!, currentIndex: _currentIndex!, - animate: true, + animate: animatePageTransitions, ), onNextPage: () => navigationService.nextPage( readerMode: readerMode!, currentIndex: _currentIndex!, maxPages: _pageViewPageCount, - animate: true, + animate: animatePageTransitions, ), onEscape: () => _goBack(context), onFullScreen: () => _setFullScreen(), @@ -422,8 +432,9 @@ class _MangaChapterPageGalleryState scrollDirection: isHorizontalContinuous ? Axis.horizontal : Axis.vertical, - minCacheExtent: - pagePreloadAmount * context.height(1), + minCacheExtent: isHorizontalContinuous + ? pagePreloadAmount * context.width(1) + : pagePreloadAmount * context.height(1), initialScrollIndex: _readerController .getPageIndex(), physics: const ClampingScrollPhysics(), @@ -758,13 +769,13 @@ class _MangaChapterPageGalleryState onPreviousPage: () => navigationService.previousPage( readerMode: readerMode!, currentIndex: _currentIndex!, - animate: true, + animate: animatePageTransitions, ), onNextPage: () => navigationService.nextPage( readerMode: readerMode!, currentIndex: _currentIndex!, maxPages: _pageViewPageCount, - animate: true, + animate: animatePageTransitions, ), onDoubleTapDown: (position) => _toggleScale(position), onDoubleTap: () {}, @@ -824,6 +835,7 @@ class _MangaChapterPageGalleryState ); }, onSliderChanged: (value, ref) { + _currentPageDisplayIndex.value = value; ref .read(currentIndexProvider(chapter).notifier) .setCurrentIndex(value); @@ -907,7 +919,7 @@ class _MangaChapterPageGalleryState }, ), currentReaderModeProvider: _currentReaderMode, - currentIndexProvider: currentIndexProvider, + currentPageListenable: _currentPageDisplayIndex, currentPageMode: _pageMode, isReverseHorizontal: _isReverseHorizontal, totalPages: _readerController.getPageLength( @@ -917,8 +929,8 @@ class _MangaChapterPageGalleryState backgroundColor: _backgroundColor, ), PageIndicator( - chapter: chapter, isUiVisible: _isView, + currentPageListenable: _currentPageDisplayIndex, totalPages: _readerController.getPageLength( _chapterUrlModel.pageUrls, ), @@ -1008,6 +1020,7 @@ class _MangaChapterPageGalleryState final idx = pages[_currentIndex!].index; if (idx != null) { + _currentPageDisplayIndex.value = idx; _readerController.setPageIndex( _isDoublePageActive ? idx : _geCurrentIndex(idx), false, @@ -1061,7 +1074,10 @@ class _MangaChapterPageGalleryState if (_isNextChapterPreloading || _isLastPageTransition) return; _isNextChapterPreloading = true; try { - if (!mounted) return; + if (!mounted) { + _isNextChapterPreloading = false; + return; + } final nextChapter = _readerController.getNextChapter(); if (isChapterLoaded(nextChapter)) { _isNextChapterPreloading = false; @@ -1155,6 +1171,7 @@ class _MangaChapterPageGalleryState void _initCurrentIndex() async { if (ref.read(cropBordersStateProvider)) _processCropBorders(); final readerMode = _readerController.getReaderMode(); + _currentPageDisplayIndex.value = _readerController.getPageIndex(); // Initialize the preload manager with bounded memory (from ReaderMemoryManagement mixin) initializePreloadManager( @@ -1167,10 +1184,14 @@ class _MangaChapterPageGalleryState }, ); + // Kick off ordered prefetch before the first frame so lower-indexed pages + // win the HTTP race against the simultaneous widget-driven loads. + _prefetchPagesInOrder(); // intentionally not awaited + // proactively start loading adjacent chapters in background _proactivePreload(); - _readerController.setMangaHistoryUpdate(); + _readerController.setHistoryUpdate(); // Use post-frame callback instead of Future.delayed(1ms) timing hack await Future(() {}); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); @@ -1202,22 +1223,55 @@ class _MangaChapterPageGalleryState } } + /// Warms Flutter's [ImageCache] in page order before the widget tree renders. + /// + /// [ScrollablePositionedList] builds all items within [minCacheExtent] in a + /// single frame, firing every network request simultaneously, which means + /// pages complete in arbitrary (server-response) order. By resolving each + /// provider sequentially here — starting before that first frame — we seed + /// the cache so that earlier pages win the HTTP race: lower-indexed pages + /// start their requests first and are therefore ready sooner. + /// + /// For pages already within the cache extent the widget will attach to the + /// already-pending Future (Flutter deduplicates by provider key), so no + /// extra requests are made. Pages beyond the cache extent are fetched + /// strictly one at a time in reading order, so the reader never sees a + /// later page appear before an earlier one. + /// + /// This is fully async — [await] inside a fire-and-forget call — so the + /// UI stays interactive throughout. + Future _prefetchPagesInOrder() async { + final startIdx = (_currentIndex ?? 0).clamp(0, pages.length - 1); + + // Visit pages from the opening position forward, then backward. + final indices = [ + for (var i = startIdx; i < pages.length; i++) i, + for (var i = startIdx - 1; i >= 0; i--) i, + ]; + + for (final i in indices) { + if (!mounted) return; + final page = pages[i]; + if (page.isTransitionPage) continue; + try { + // Awaiting ensures page[i] finishes (or fails) before page[i+1] + // starts downloading, giving strict reading-order priority. + await precacheImage(page.getImageProvider(ref, true), context); + } catch (_) { + // Swallow errors: network failures, widget disposal, etc. + } + } + } + Future _onPageChanged(int index) async { // In non-continuous double page mode, convert page view index to actual // pages array index for correct lookups. final int actualIndex = _pageViewToActualIndex(index); final int prevActualIndex = _pageViewToActualIndex(_currentIndex!); - final cropBorders = ref.watch(cropBordersStateProvider); if (cropBorders) { _processCropBordersByIndex(index); } - if (_firstLaunch) { - Future.delayed(const Duration(milliseconds: 100)).then((_) { - _firstLaunch = false; - }); - return; - } final idx = pages[prevActualIndex].index; if (idx != null) { _readerController.setPageIndex( @@ -1246,6 +1300,7 @@ class _MangaChapterPageGalleryState clearGestureDetailsCache(); _currentIndex = index; if (pages[actualIndex].index != null) { + _currentPageDisplayIndex.value = pages[actualIndex].index!; ref .read(currentIndexProvider(chapter).notifier) .setCurrentIndex(pages[actualIndex].index!); @@ -1324,6 +1379,9 @@ class _MangaChapterPageGalleryState _failedToLoadImage.value = false; _readerController.setReaderMode(value); + // Cache the reader mode for safe access in dispose + _cachedReaderMode = value; + int index = _pageViewToActualIndex(_currentIndex!); ref.read(_currentReaderMode.notifier).state = value; if (value == ReaderMode.vertical) { @@ -1451,11 +1509,19 @@ class _MangaChapterPageGalleryState /// Whether double page mode is active (continuous or paged). /// Horizontal continuous mode does NOT use double page layout. + /// Uses ref.read() so cannot be called during dispose. bool get _isDoublePageActive => _pageMode == PageMode.doublePage && ref.read(_currentReaderMode) != ReaderMode.horizontalContinuous && ref.read(_currentReaderMode) != ReaderMode.horizontalContinuousRTL; + /// Safe version of _isDoublePageActive that uses cached reader mode. + /// Safe to call during dispose without Riverpod assertion errors. + bool get _isDoublePageActiveSync => + _pageMode == PageMode.doublePage && + _cachedReaderMode != ReaderMode.horizontalContinuous && + _cachedReaderMode != ReaderMode.horizontalContinuousRTL; + /// Converts a page view index (from ExtendedPageController) to the actual /// index in the [pages] array for double page mode. /// @@ -1467,6 +1533,12 @@ class _MangaChapterPageGalleryState return (pageViewIndex * 2).clamp(0, pages.length - 1); } + /// Safe version that uses cached reader mode for use in dispose. + int _pageViewToActualIndexSync(int pageViewIndex) { + if (!_isDoublePageActiveSync) return pageViewIndex; + return (pageViewIndex * 2).clamp(0, pages.length - 1); + } + /// Converts an actual [pages] array index to a page view index /// for double page mode. int _actualToPageViewIndex(int actualIndex) { diff --git a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart index 7b2661a7..07236d9b 100644 --- a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart +++ b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart @@ -4,7 +4,7 @@ import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/utils/extensions/manga.dart'; import 'package:mangayomi/modules/manga/reader/reader_view.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/date.dart'; diff --git a/lib/modules/manga/reader/widgets/chapter_transition_page.dart b/lib/modules/manga/reader/widgets/chapter_transition_page.dart index 2965e98f..a9819c61 100644 --- a/lib/modules/manga/reader/widgets/chapter_transition_page.dart +++ b/lib/modules/manga/reader/widgets/chapter_transition_page.dart @@ -1,216 +1,323 @@ import 'package:flutter/material.dart'; import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; class ChapterTransitionPage extends StatelessWidget { final Chapter currentChapter; final Chapter? nextChapter; final String mangaName; + final ReaderMode readerMode; const ChapterTransitionPage({ super.key, required this.currentChapter, required this.nextChapter, required this.mangaName, + required this.readerMode, }); + bool get _isVertical => + readerMode == ReaderMode.vertical || + readerMode == ReaderMode.verticalContinuous || + readerMode == ReaderMode.webtoon; + + bool get _isRTL => + readerMode == ReaderMode.rtl || + readerMode == ReaderMode.horizontalContinuousRTL; + @override Widget build(BuildContext context) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.width; - final l10n = context.l10n; return Container( - constraints: BoxConstraints(minHeight: screenHeight * 0.5), color: Theme.of(context).scaffoldBackgroundColor, child: Center( - child: Padding( - padding: EdgeInsets.all(screenWidth * 0.08), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Icône de transition - Icon( - Icons.auto_stories_outlined, - size: screenWidth * 0.16, - color: Theme.of(context).colorScheme.primary, - ), - - SizedBox(height: screenHeight * 0.04), - - // Titre - Text( - l10n.end_of_chapter, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - - SizedBox(height: screenHeight * 0.03), - - Container( - padding: EdgeInsets.all(screenWidth * 0.04), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), + child: LayoutBuilder( + builder: (context, constraints) { + return FittedBox( + fit: BoxFit.scaleDown, + child: ConstrainedBox( + // Give the content a natural maximum size to fit within. + // FittedBox will scale it down if the available space is smaller. + constraints: BoxConstraints( + maxWidth: _isVertical + ? constraints.maxWidth.clamp(100.0, 480.0) + : constraints.maxWidth.clamp(100.0, double.infinity), + maxHeight: constraints.maxHeight.clamp( + 100.0, + double.infinity, ), ), - child: Column( - children: [ - Text( - l10n.chapter_completed, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - SizedBox(height: screenHeight * 0.01), - Text( - currentChapter.name ?? 'Chapitre ${currentChapter.id}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], + child: Padding( + padding: const EdgeInsets.all(24.0), + child: _isVertical + ? _buildVerticalLayout(context) + : _buildHorizontalLayout(context), ), ), + ); + }, + ), + ), + ); + } - SizedBox(height: screenHeight * 0.03), + // ── Vertical layout (top → arrow → bottom) ──────────────────────────────── - Icon( - nextChapter != null - ? Icons.keyboard_arrow_down - : Icons.check_circle_outline, - size: screenWidth * 0.08, - color: nextChapter != null - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - - SizedBox(height: screenHeight * 0.03), - - if (nextChapter != null) ...[ - Container( - padding: EdgeInsets.all(screenWidth * 0.04), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Column( - children: [ - Text( - l10n.next_chapter, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.8), - ), - ), - SizedBox(height: screenHeight * 0.01), - Text( - nextChapter!.name ?? 'Chapitre ${nextChapter!.id}', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - SizedBox(height: screenHeight * 0.04), - Text( - l10n.continue_to_next_chapter, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - textAlign: TextAlign.center, - ), - ] else ...[ - Container( - padding: EdgeInsets.all(screenWidth * 0.04), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: Column( - children: [ - Icon( - Icons.last_page, - size: screenWidth * 0.06, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - SizedBox(height: screenHeight * 0.01), - Text( - l10n.no_next_chapter, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.8), - ), - textAlign: TextAlign.center, - ), - SizedBox(height: screenHeight * 0.005), - Text( - l10n.you_have_finished_reading, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - SizedBox(height: screenHeight * 0.04), - Text( - l10n.return_to_the_list_of_chapters, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - textAlign: TextAlign.center, - ), - ], - ], + Widget _buildVerticalLayout(BuildContext context) { + final l10n = context.l10n; + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.auto_stories_outlined, + size: 48, + color: Theme.of(context).colorScheme.primary, ), + const SizedBox(height: 20), + Text( + l10n.end_of_chapter, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + _buildChapterCard( + context, + label: l10n.chapter_completed, + name: currentChapter.name ?? 'Chapter ${currentChapter.id}', + isPrimary: false, + ), + const SizedBox(height: 16), + Icon( + nextChapter != null + ? Icons.keyboard_arrow_down + : Icons.check_circle_outline, + size: 32, + color: nextChapter != null + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + if (nextChapter != null) ...[ + _buildChapterCard( + context, + label: l10n.next_chapter, + name: nextChapter!.name ?? 'Chapter ${nextChapter!.id}', + isPrimary: true, + ), + const SizedBox(height: 20), + Text( + l10n.continue_to_next_chapter, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ] else ...[ + _buildEndOfMangaCard(context), + const SizedBox(height: 20), + Text( + l10n.return_to_the_list_of_chapters, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } + + // ── Horizontal layout (left → arrow → right) ────────────────────────────── + + Widget _buildHorizontalLayout(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + // For LTR: [current] → [next] + // For RTL: [next] ← [current] + final Widget currentCard = _buildChapterCard( + context, + label: l10n.chapter_completed, + name: currentChapter.name ?? 'Chapter ${currentChapter.id}', + isPrimary: false, + ); + + final Widget arrowIcon = Icon( + nextChapter != null + ? (_isRTL ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right) + : Icons.check_circle_outline, + size: 36, + color: nextChapter != null + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.6), + ); + + final Widget nextCard = nextChapter != null + ? _buildChapterCard( + context, + label: l10n.next_chapter, + name: nextChapter!.name ?? 'Chapter ${nextChapter!.id}', + isPrimary: true, + ) + : _buildEndOfMangaCard(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_stories_outlined, + size: 40, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + l10n.end_of_chapter, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _isRTL + ? [ + Expanded(child: nextCard), + const SizedBox(width: 12), + Center(child: arrowIcon), + const SizedBox(width: 12), + Expanded(child: currentCard), + ] + : [ + Expanded(child: currentCard), + const SizedBox(width: 12), + Center(child: arrowIcon), + const SizedBox(width: 12), + Expanded(child: nextCard), + ], + ), + ), + const SizedBox(height: 16), + Text( + nextChapter != null + ? l10n.continue_to_next_chapter + : l10n.return_to_the_list_of_chapters, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + // ── Shared card widgets ──────────────────────────────────────────────────── + + Widget _buildChapterCard( + BuildContext context, { + required String label, + required String name, + required bool isPrimary, + }) { + final theme = Theme.of(context); + final bgColor = isPrimary + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surface; + final borderColor = isPrimary + ? theme.colorScheme.primary.withValues(alpha: 0.3) + : theme.colorScheme.outline.withValues(alpha: 0.2); + final labelColor = isPrimary + ? theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8) + : theme.colorScheme.onSurface.withValues(alpha: 0.7); + final nameColor = isPrimary ? theme.colorScheme.onPrimaryContainer : null; + + return SizedBox( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith(color: labelColor), + maxLines: 2, + ), + const SizedBox(height: 6), + Text( + name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: nameColor, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + Widget _buildEndOfMangaCard(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + return SizedBox( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.last_page, + size: 24, + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(height: 6), + Text( + l10n.no_next_chapter, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withValues(alpha: 0.8), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + l10n.you_have_finished_reading, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], ), ), ); diff --git a/lib/modules/manga/reader/widgets/page_indicator.dart b/lib/modules/manga/reader/widgets/page_indicator.dart index 08d0d613..77548807 100644 --- a/lib/modules/manga/reader/widgets/page_indicator.dart +++ b/lib/modules/manga/reader/widgets/page_indicator.dart @@ -1,7 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mangayomi/models/chapter.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; /// Page indicator widget showing current page / total pages. @@ -9,12 +8,12 @@ import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_pr /// Displayed at the bottom center when the UI is hidden and /// "show page numbers" setting is enabled. class PageIndicator extends ConsumerWidget { - /// The current chapter being read. - final Chapter chapter; - /// Whether the UI overlay is currently visible. final bool isUiVisible; + /// Session-local current page index for the visible reader state. + final ValueListenable currentPageListenable; + /// Total number of pages. final int totalPages; @@ -23,15 +22,14 @@ class PageIndicator extends ConsumerWidget { const PageIndicator({ super.key, - required this.chapter, required this.isUiVisible, + required this.currentPageListenable, required this.totalPages, required this.formatCurrentIndex, }); @override Widget build(BuildContext context, WidgetRef ref) { - final currentIndex = ref.watch(currentIndexProvider(chapter)); final showPagesNumber = ref.watch(showPagesNumberStateProvider); // Don't show when UI is visible or setting is disabled @@ -41,19 +39,24 @@ class PageIndicator extends ConsumerWidget { return Align( alignment: Alignment.bottomCenter, - child: Text( - '${formatCurrentIndex(currentIndex)} / $totalPages', - style: const TextStyle( - color: Colors.white, - fontSize: 20.0, - shadows: [ - Shadow(offset: Offset(-1, -1), blurRadius: 1), - Shadow(offset: Offset(1, -1), blurRadius: 1), - Shadow(offset: Offset(1, 1), blurRadius: 1), - Shadow(offset: Offset(-1, 1), blurRadius: 1), - ], - ), - textAlign: TextAlign.center, + child: ValueListenableBuilder( + valueListenable: currentPageListenable, + builder: (context, currentIndex, child) { + return Text( + '${formatCurrentIndex(currentIndex)} / $totalPages', + style: const TextStyle( + color: Colors.white, + fontSize: 20.0, + shadows: [ + Shadow(offset: Offset(-1, -1), blurRadius: 1), + Shadow(offset: Offset(1, -1), blurRadius: 1), + Shadow(offset: Offset(1, 1), blurRadius: 1), + Shadow(offset: Offset(-1, 1), blurRadius: 1), + ], + ), + textAlign: TextAlign.center, + ); + }, ), ); } diff --git a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart index f4b8eb53..8c93596b 100644 --- a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'dart:math'; import 'package:flutter/cupertino.dart'; @@ -6,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show ProviderListenable; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; import 'package:mangayomi/modules/manga/reader/widgets/custom_value_indicator_shape.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; @@ -62,9 +62,8 @@ class ReaderBottomBar extends ConsumerWidget { /// (StateProvider, NotifierProvider, etc.) final ProviderListenable currentReaderModeProvider; - /// Provider family for watching current page index - /// Type: CurrentIndexFamily (from reader_controller_provider.g.dart) - final CurrentIndexFamily currentIndexProvider; + /// Session-local current page index for the visible reader state. + final ValueListenable currentPageListenable; /// Current page mode (nullable for safety) final PageMode? currentPageMode; @@ -95,7 +94,7 @@ class ReaderBottomBar extends ConsumerWidget { this.onPageModeToggle, required this.onSettingsPressed, required this.currentReaderModeProvider, - required this.currentIndexProvider, + required this.currentPageListenable, required this.currentPageMode, required this.isReverseHorizontal, required this.totalPages, @@ -194,15 +193,17 @@ class ReaderBottomBar extends ConsumerWidget { child: Center( child: Consumer( builder: (context, ref, child) { - final currentIndex = ref.watch( - currentIndexProvider(chapter), - ); - return Text( - currentIndexLabel(currentIndex), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), + return ValueListenableBuilder( + valueListenable: currentPageListenable, + builder: (context, currentIndex, child) { + return Text( + currentIndexLabel(currentIndex), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ); + }, ); }, ), @@ -277,34 +278,37 @@ class ReaderBottomBar extends ConsumerWidget { ) { return Consumer( builder: (context, ref, child) { - final currentIndex = ref.watch(currentIndexProvider(chapter)); + return ValueListenableBuilder( + valueListenable: currentPageListenable, + builder: (context, currentIndex, child) { + final maxValue = (totalPages - 1).toDouble(); - final maxValue = (totalPages - 1).toDouble(); + final divisions = totalPages <= 1 ? null : totalPages - 1; - final divisions = totalPages <= 1 ? null : totalPages - 1; + final currentValue = min(currentIndex.toDouble(), maxValue); - final currentValue = min(currentIndex.toDouble(), maxValue); - - return SliderTheme( - data: SliderTheme.of(context).copyWith( - valueIndicatorShape: CustomValueIndicatorShape( - tranform: isReverseHorizontal, - ), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0), - ), - child: Slider( - onChanged: (value) { - onSliderChanged(value.toInt(), ref); - }, - onChangeEnd: (newValue) { - onSliderChangeEnd(newValue.toInt()); - }, - divisions: divisions, - value: currentValue, - label: currentIndexLabel(currentIndex), - min: 0, - max: maxValue, - ), + return SliderTheme( + data: SliderTheme.of(context).copyWith( + valueIndicatorShape: CustomValueIndicatorShape( + tranform: isReverseHorizontal, + ), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0), + ), + child: Slider( + onChanged: (value) { + onSliderChanged(value.toInt(), ref); + }, + onChangeEnd: (newValue) { + onSliderChangeEnd(newValue.toInt()); + }, + divisions: divisions, + value: currentValue, + label: currentIndexLabel(currentIndex), + min: 0, + max: maxValue, + ), + ); + }, ); }, ); diff --git a/lib/modules/manga/reader/widgets/transition_view_paged.dart b/lib/modules/manga/reader/widgets/transition_view_paged.dart index 57f8bb75..d8dbf226 100644 --- a/lib/modules/manga/reader/widgets/transition_view_paged.dart +++ b/lib/modules/manga/reader/widgets/transition_view_paged.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart'; import 'package:mangayomi/modules/manga/reader/widgets/chapter_transition_page.dart'; @@ -14,10 +15,15 @@ class TransitionViewPaged extends ConsumerWidget { return const SizedBox.shrink(); } + final readerMode = ref + .read(readerControllerProvider(chapter: data.chapter!).notifier) + .getReaderMode(); + return ChapterTransitionPage( currentChapter: data.chapter!, nextChapter: data.nextChapter, mangaName: data.mangaName ?? '', + readerMode: readerMode, ); } } diff --git a/lib/modules/manga/reader/widgets/transition_view_vertical.dart b/lib/modules/manga/reader/widgets/transition_view_vertical.dart index 2a80e717..5e0f233a 100644 --- a/lib/modules/manga/reader/widgets/transition_view_vertical.dart +++ b/lib/modules/manga/reader/widgets/transition_view_vertical.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart'; import 'package:mangayomi/modules/manga/reader/widgets/chapter_transition_page.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; @@ -15,12 +16,17 @@ class TransitionViewVertical extends ConsumerWidget { return const SizedBox.shrink(); } + final readerMode = ref + .read(readerControllerProvider(chapter: data.chapter!).notifier) + .getReaderMode(); + return SizedBox( height: context.height(1), child: ChapterTransitionPage( currentChapter: data.chapter!, nextChapter: data.nextChapter, mangaName: data.mangaName ?? '', + readerMode: readerMode, ), ); } diff --git a/lib/modules/mass_migration/services/mass_migration_service.dart b/lib/modules/mass_migration/services/mass_migration_service.dart index b3eaf134..e66d926c 100644 --- a/lib/modules/mass_migration/services/mass_migration_service.dart +++ b/lib/modules/mass_migration/services/mass_migration_service.dart @@ -1,5 +1,3 @@ -import 'dart:collection'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar_community/isar.dart'; import 'package:mangayomi/eval/model/m_manga.dart'; @@ -15,7 +13,7 @@ import 'package:mangayomi/modules/mass_migration/models/mass_migration_models.da import 'package:mangayomi/modules/manga/detail/providers/isar_providers.dart'; import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; import 'package:mangayomi/services/get_detail.dart'; -import 'package:mangayomi/services/search_.dart'; +import 'package:mangayomi/services/search.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; Future migrateLibraryItem({ diff --git a/lib/modules/more/about/providers/check_for_update.dart b/lib/modules/more/about/providers/check_for_update.dart index 099b0086..1f80d49d 100644 --- a/lib/modules/more/about/providers/check_for_update.dart +++ b/lib/modules/more/about/providers/check_for_update.dart @@ -21,7 +21,7 @@ Future checkForUpdate( bool? manualUpdate, }) async { manualUpdate = manualUpdate ?? false; - final checkForUpdates = ref.watch(checkForAppUpdatesProvider); + final checkForUpdates = ref.read(checkForAppUpdatesProvider); if (!checkForUpdates && !manualUpdate) return; final l10n = l10nLocalizations(context!)!; diff --git a/lib/modules/novel/novel_reader_controller_provider.dart b/lib/modules/novel/novel_reader_controller_provider.dart index c390f401..daae6821 100644 --- a/lib/modules/novel/novel_reader_controller_provider.dart +++ b/lib/modules/novel/novel_reader_controller_provider.dart @@ -1,110 +1,33 @@ -import 'package:isar_community/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; -import 'package:mangayomi/models/history.dart'; -import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'novel_reader_controller_provider.g.dart'; @riverpod -class NovelReaderController extends _$NovelReaderController { +class NovelReaderController extends _$NovelReaderController + with ChapterControllerMixin, ChapterReaderSettingsMixin { @override void build({required Chapter chapter}) {} - Manga getManga() { - return chapter.manga.value!; - } + // Keep incognitoMode as a final field (read once, not on every access). + @override + final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!; - Chapter geChapter() { - return chapter; - } + // Override getIsarSetting to add per-instance caching; callers that mutate + // settings must call _invalidateSettingsCache() afterwards. + Settings? _cachedSettings; + @override + void onSettingsMutated() => _cachedSettings = null; - final incognitoMode = isar.settings.getSync(227)!.incognitoMode!; + @override + Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!; - Settings getIsarSetting() { - return isar.settings.getSync(227)!; - } - - (bool, double) autoScrollValues() { - final autoScrollPagesList = getIsarSetting().autoScrollPages ?? []; - final autoScrollPages = autoScrollPagesList.where( - (element) => element.mangaId == getManga().id, - ); - if (autoScrollPages.isNotEmpty) { - return ( - autoScrollPages.first.autoScroll ?? false, - autoScrollPages.first.pageOffset ?? 10, - ); - } - return (false, 10); - } - - void setAutoScroll(bool value, double offset) { - List? autoScrollPagesList = []; - for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) { - if (autoScrollPages.mangaId != getManga().id) { - autoScrollPagesList.add(autoScrollPages); - } - } - autoScrollPagesList.add( - AutoScrollPages() - ..mangaId = getManga().id - ..pageOffset = offset - ..autoScroll = value, - ); - isar.writeTxnSync( - () => isar.settings.putSync( - getIsarSetting() - ..autoScrollPages = autoScrollPagesList - ..updatedAt = DateTime.now().millisecondsSinceEpoch, - ), - ); - } - - void setMangaHistoryUpdate({int readingTimeSeconds = 0}) { - if (incognitoMode) return; - isar.writeTxnSync(() { - Manga? manga = chapter.manga.value; - manga!.lastRead = DateTime.now().millisecondsSinceEpoch; - manga.updatedAt = DateTime.now().millisecondsSinceEpoch; - isar.mangas.putSync(manga); - }); - History? history; - - final empty = isar.historys - .filter() - .mangaIdEqualTo(getManga().id) - .isEmptySync(); - - if (empty) { - history = History( - mangaId: getManga().id, - date: DateTime.now().millisecondsSinceEpoch.toString(), - itemType: getManga().itemType, - chapterId: chapter.id, - )..chapter.value = chapter; - } else { - history = - (isar.historys - .filter() - .mangaIdEqualTo(getManga().id) - .findFirstSync())! - ..chapterId = chapter.id - ..chapter.value = chapter - ..date = DateTime.now().millisecondsSinceEpoch.toString(); - } - isar.writeTxnSync(() { - history!.updatedAt = DateTime.now().millisecondsSinceEpoch; - if (readingTimeSeconds > 0) { - history.readingTimeSeconds = - (history.readingTimeSeconds ?? 0) + readingTimeSeconds; - } - isar.historys.putSync(history); - history.chapter.saveSync(); - }); - } + // --------------------------------------------------------------------------- + // Scroll-position tracking + // --------------------------------------------------------------------------- void setChapterOffset(double newOffset, double maxOffset, bool save) { if (incognitoMode) return; @@ -120,111 +43,4 @@ class NovelReaderController extends _$NovelReaderController { }); } } - - void setChapterBookmarked() { - if (incognitoMode) return; - final isBookmarked = getChapterBookmarked(); - final chap = chapter; - isar.writeTxnSync(() { - chap.isBookmarked = !isBookmarked; - chap.updatedAt = DateTime.now().millisecondsSinceEpoch; - isar.chapters.putSync(chap); - }); - } - - bool getChapterBookmarked() { - return isar.chapters.getSync(chapter.id!)!.isBookmarked!; - } - - (int, bool) getPrevChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i + 1; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i + 1; - } - } - return (index!, false); - } - return (index, true); - } - - (int, bool) getNextChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i - 1; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i - 1; - } - } - return (index!, false); - } - return (index, true); - } - - (int, bool) getChapterIndex() { - final chapters = getManga().getFilteredChapterList(); - int? index; - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i; - } - } - if (index == null) { - final chapters = getManga().chapters.toList().toList(); - for (var i = 0; i < chapters.length; i++) { - if (chapters[i].id == chapter.id) { - index = i; - } - } - return (index!, false); - } - return (index, true); - } - - Chapter getPrevChapter() { - final prevChapIdx = getPrevChapterIndex(); - return prevChapIdx.$2 - ? getManga().getFilteredChapterList()[prevChapIdx.$1] - : getManga().chapters.toList().toList()[prevChapIdx.$1]; - } - - Chapter getNextChapter() { - final nextChapIdx = getNextChapterIndex(); - return nextChapIdx.$2 - ? getManga().getFilteredChapterList()[nextChapIdx.$1] - : getManga().chapters.toList().toList()[nextChapIdx.$1]; - } - - int getChaptersLength(bool isInFilterList) { - return isInFilterList - ? getManga().getFilteredChapterList().length - : getManga().chapters.length; - } - - String getMangaName() { - return getManga().name!; - } - - String getSourceName() { - return getManga().source!; - } - - String getChapterTitle() { - return chapter.name!; - } } diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index 261e9cb8..95081270 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -90,8 +90,8 @@ class _NovelWebViewState extends ConsumerState _readingStopwatch.stop(); WidgetsBinding.instance.removeObserver(this); _readerController.setChapterOffset(offset, maxOffset, true); - _readerController.setMangaHistoryUpdate( - readingTimeSeconds: _readingStopwatch.elapsed.inSeconds, + _readerController.setHistoryUpdate( + elapsedSeconds: _readingStopwatch.elapsed.inSeconds, ); _scrollController.removeListener(onScroll); _scrollController.dispose(); diff --git a/lib/services/get_chapter_pages.dart b/lib/services/get_chapter_pages.dart index d6653752..22cbd6fe 100644 --- a/lib/services/get_chapter_pages.dart +++ b/lib/services/get_chapter_pages.dart @@ -40,7 +40,6 @@ Future getChapterPages( }) async { final keepAlive = ref.keepAlive(); try { - List uChapDataPreloadp = []; Directory? path; List pageUrls = []; List isLocaleList = []; @@ -86,6 +85,14 @@ Future getChapterPages( } } + final chapterModel = GetChapterPagesModel( + path: path, + pageUrls: pageUrls, + isLocaleList: isLocaleList, + archiveImages: archiveImages, + uChapDataPreload: [], + ); + if (pageUrls.isNotEmpty || isLocalArchive) { if (await File( p.join(mangaDirectory!.path, "${chapter.name}.cbz"), @@ -144,7 +151,7 @@ Future getChapterPages( }); } for (var i = 0; i < pageUrls.length; i++) { - uChapDataPreloadp.add( + chapterModel.uChapDataPreload.add( UChapDataPreload( chapter, path, @@ -152,26 +159,14 @@ Future getChapterPages( isLocaleList[i], archiveImages[i], i, - GetChapterPagesModel( - path: path, - pageUrls: pageUrls, - isLocaleList: isLocaleList, - archiveImages: archiveImages, - uChapDataPreload: uChapDataPreloadp, - ), + chapterModel, i, ), ); } } keepAlive.close(); - return GetChapterPagesModel( - path: path, - pageUrls: pageUrls, - isLocaleList: isLocaleList, - archiveImages: archiveImages, - uChapDataPreload: uChapDataPreloadp, - ); + return chapterModel; } catch (e) { keepAlive.close(); rethrow; diff --git a/lib/services/search_.dart b/lib/services/search_.dart deleted file mode 100644 index 7ae16a41..00000000 --- a/lib/services/search_.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:isar_community/isar.dart'; -import 'package:mangayomi/eval/model/m_manga.dart'; -import 'package:mangayomi/eval/model/m_pages.dart'; -import 'package:mangayomi/main.dart'; -import 'package:mangayomi/models/manga.dart'; -import 'package:mangayomi/models/source.dart'; -import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart'; -import 'package:mangayomi/services/isolate_service.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'search_.g.dart'; - -@riverpod -Future search( - Ref ref, { - required Source source, - required String query, - required int page, - required List filterList, -}) async { - if (source.name == "local" && source.lang == "") { - final result = - (await isar.mangas - .filter() - .group( - (q) => q - .sourceEqualTo("local") - .or() - .linkContains("Mangayomi/local") - .or() - .linkContains("Mangayomi\\local"), - ) - .nameContains(query, caseSensitive: false) - .offset(page * 50) - .limit(50) - .findAll()) - .map((e) => MManga(name: e.name)) - .toList(); - return MPages(list: result, hasNextPage: true); - } - return getIsolateService.get( - query: query, - filterList: filterList, - source: source, - page: page, - serviceType: 'search', - proxyServer: ref.read(androidProxyServerStateProvider), - ); -} diff --git a/lib/services/search_.g.dart b/lib/services/search_.g.dart deleted file mode 100644 index 7d76a639..00000000 --- a/lib/services/search_.g.dart +++ /dev/null @@ -1,109 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search_.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(search) -final searchProvider = SearchFamily._(); - -final class SearchProvider - extends $FunctionalProvider, MPages?, FutureOr> - with $FutureModifier, $FutureProvider { - SearchProvider._({ - required SearchFamily super.from, - required ({Source source, String query, int page, List filterList}) - super.argument, - }) : super( - retry: null, - name: r'searchProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$searchHash(); - - @override - String toString() { - return r'searchProvider' - '' - '$argument'; - } - - @$internal - @override - $FutureProviderElement $createElement($ProviderPointer pointer) => - $FutureProviderElement(pointer); - - @override - FutureOr create(Ref ref) { - final argument = - this.argument - as ({ - Source source, - String query, - int page, - List filterList, - }); - return search( - ref, - source: argument.source, - query: argument.query, - page: argument.page, - filterList: argument.filterList, - ); - } - - @override - bool operator ==(Object other) { - return other is SearchProvider && other.argument == argument; - } - - @override - int get hashCode { - return argument.hashCode; - } -} - -String _$searchHash() => r'0fa9d882436b1b58b3420dae5a757e7622273eb5'; - -final class SearchFamily extends $Family - with - $FunctionalFamilyOverride< - FutureOr, - ({Source source, String query, int page, List filterList}) - > { - SearchFamily._() - : super( - retry: null, - name: r'searchProvider', - dependencies: null, - $allTransitiveDependencies: null, - isAutoDispose: true, - ); - - SearchProvider call({ - required Source source, - required String query, - required int page, - required List filterList, - }) => SearchProvider._( - argument: ( - source: source, - query: query, - page: page, - filterList: filterList, - ), - from: this, - ); - - @override - String toString() => r'searchProvider'; -} diff --git a/lib/utils/extensions/chapter.dart b/lib/utils/extensions/chapter.dart index a2081a88..f469f97c 100644 --- a/lib/utils/extensions/chapter.dart +++ b/lib/utils/extensions/chapter.dart @@ -1,13 +1,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/track.dart'; +import 'package:mangayomi/models/track_preference.dart'; +import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; -import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/utils/extensions/manga.dart'; +import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/services/download_manager/download_isolate_pool.dart'; import 'package:mangayomi/services/download_manager/m_downloader.dart'; +import 'package:mangayomi/utils/chapter_recognition.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:path/path.dart' as p; @@ -19,9 +27,9 @@ extension ChapterExtension on Chapter { if (ignoreIsRead || !isRead!) { await pushMangaReaderView(context: context, chapter: this); } else { - final filteredChaps = manga.value!.getFilteredChapterList(); + final filteredChaps = manga.value!.getChapterListForReading(); bool exist = false; - for (var filteredChap in filteredChaps.reversed) { + for (var filteredChap in filteredChaps) { if (filteredChap.toJson().toString() == toJson().toString()) { exist = true; } @@ -80,4 +88,59 @@ extension ChapterExtension on Chapter { cancelDownloads(download.id); } + + void updateTrackChapterRead(dynamic ref) { + if (!(ref is WidgetRef || ref is Ref)) return; + final updateProgressAfterReading = ref.read( + updateProgressAfterReadingStateProvider, + ); + if (!updateProgressAfterReading) return; + final manga = this.manga.value!; + final chapterNumber = ChapterRecognition().parseChapterNumber( + manga.name!, + name!, + ); + + final tracks = isar.tracks + .filter() + .idIsNotNull() + .itemTypeEqualTo(manga.itemType) + .mangaIdEqualTo(manga.id!) + .findAllSync(); + + if (tracks.isEmpty) return; + for (var track in tracks) { + final service = isar.trackPreferences + .filter() + .syncIdIsNotNull() + .syncIdEqualTo(track.syncId) + .findFirstSync(); + if (!(service == null || chapterNumber <= (track.lastChapterRead ?? 0))) { + if (track.status != TrackStatus.completed) { + track.lastChapterRead = chapterNumber; + if (track.lastChapterRead == track.totalChapter && + (track.totalChapter ?? 0) > 0) { + track.status = TrackStatus.completed; + track.finishedReadingDate = DateTime.now().millisecondsSinceEpoch; + } else { + track.status = manga.itemType == ItemType.manga + ? TrackStatus.reading + : TrackStatus.watching; + if (track.lastChapterRead == 1) { + track.startedReadingDate = DateTime.now().millisecondsSinceEpoch; + } + } + } + ref + .read( + trackStateProvider( + track: track, + itemType: manga.itemType, + widgetRef: ref, + ).notifier, + ) + .updateManga(); + } + } + } } diff --git a/lib/utils/extensions/manga.dart b/lib/utils/extensions/manga.dart new file mode 100644 index 00000000..ba6e5ad7 --- /dev/null +++ b/lib/utils/extensions/manga.dart @@ -0,0 +1,124 @@ +import 'package:isar_community/isar.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/download.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/settings.dart'; + +extension MangaExtensions on Manga { + // ── For the READER: always ascending story order, filters applied ────────── + List getChapterListForReading() { + final settings = isar.settings.getSync(227)!; + + final filterUnread = + (settings.chapterFilterUnreadList! + .where((e) => e.mangaId == id) + .firstOrNull ?? + ChapterFilterUnread(mangaId: id, type: 0)) + .type!; + + final filterBookmarked = + (settings.chapterFilterBookmarkedList! + .where((e) => e.mangaId == id) + .firstOrNull ?? + ChapterFilterBookmarked(mangaId: id, type: 0)) + .type!; + + final filterDownloaded = + (settings.chapterFilterDownloadedList! + .where((e) => e.mangaId == id) + .firstOrNull ?? + ChapterFilterDownloaded(mangaId: id, type: 0)) + .type!; + + final scanlators = settings.filterScanlatorList ?? []; + final filter = scanlators.where((e) => e.mangaId == id).toList(); + final filterScanlator = filter.firstOrNull?.scanlators ?? []; + + // Canonical ascending order (ch1 ... chN) — reader always moves forward. + final data = chapters + .toList(); // keep DB/insertion order, assumed ascending + + final chapterIds = data.map((c) => c.id).whereType().toList(); + final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty) + ? const {} + : isar.downloads + .filter() + .anyOf(chapterIds, (q, id) => q.idEqualTo(id)) + .isDownloadEqualTo(true) + .findAllSync() + .map((d) => d.id!) + .toSet(); + + return data + .where( + (e) => filterUnread == 1 + ? e.isRead == false + : filterUnread == 2 + ? e.isRead == true + : true, + ) + .where( + (e) => filterBookmarked == 1 + ? e.isBookmarked == true + : filterBookmarked == 2 + ? e.isBookmarked == false + : true, + ) + .where((e) { + if (filterDownloaded == 0) return true; + final dl = downloadedIds.contains(e.id); + return filterDownloaded == 1 ? dl : !dl; + }) + .where((e) => !filterScanlator.contains(e.scanlator)) + .toList() + .reversed + .toList(); + } + + // ── For the UI LIST: filters + user-chosen sort + reverse ───────────────── + List getFilteredChapterList() { + final settings = isar.settings.getSync(227)!; + + final sortChapterEntry = + settings.sortChapterList!.where((e) => e.mangaId == id).firstOrNull ?? + SortChapter(mangaId: id, index: 1, reverse: false); + final sortIndex = sortChapterEntry.index!; + final reverse = sortChapterEntry.reverse!; + + // Start from the reading list so filter logic lives in one place. + List list = getChapterListForReading(); + + switch (sortIndex) { + case 0: // by scanlator, then date + list.sort((a, b) { + if (a.scanlator == null || b.scanlator == null) return 0; + final s = a.scanlator!.compareTo(b.scanlator!); + if (s != 0) return s; + if (a.dateUpload == null || b.dateUpload == null) return 0; + return (int.tryParse(a.dateUpload!) ?? 0).compareTo( + int.tryParse(b.dateUpload!) ?? 0, + ); + }); + break; + case 1: // by chapter number - reading list is already ascending + break; + case 2: // by upload date + list.sort((a, b) { + if (a.dateUpload == null || b.dateUpload == null) return 0; + return (int.tryParse(a.dateUpload!) ?? 0).compareTo( + int.tryParse(b.dateUpload!) ?? 0, + ); + }); + break; + case 3: // by name + list.sort((a, b) { + if (a.name == null || b.name == null) return 0; + return a.name!.compareTo(b.name!); + }); + break; + } + + return reverse ? list.reversed.toList() : list; + } +}