From 0a2c8e26496a2975a8ad59f91fae0c29514bf339 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:53:25 +0200 Subject: [PATCH 01/30] rename files manga.dart and chapter.dart --- .../anime/providers/anime_player_controller_provider.dart | 2 +- lib/modules/history/history_screen.dart | 2 +- lib/modules/library/providers/library_state_provider.dart | 2 +- lib/modules/library/widgets/continue_reader_button.dart | 2 +- lib/modules/manga/detail/manga_detail_view.dart | 2 +- lib/modules/manga/detail/manga_details_view.dart | 2 +- lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart | 2 +- lib/modules/manga/download/download_page_widget.dart | 2 +- lib/modules/manga/download/providers/download_provider.dart | 2 +- lib/modules/manga/reader/mixins/chapter_controller_mixin.dart | 2 +- .../manga/reader/providers/reader_controller_provider.dart | 2 +- lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart | 2 +- lib/modules/more/download_queue/download_queue_screen.dart | 2 +- .../updates/widgets/update_chapter_list_tile_widget.dart | 2 +- lib/utils/extensions/{chapter.dart => chapter_extensions.dart} | 2 +- lib/utils/extensions/{manga.dart => manga_extensions.dart} | 0 16 files changed, 15 insertions(+), 15 deletions(-) rename lib/utils/extensions/{chapter.dart => chapter_extensions.dart} (98%) rename lib/utils/extensions/{manga.dart => manga_extensions.dart} (100%) diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 23bdc76f..f8558100 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -5,7 +5,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.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'; diff --git a/lib/modules/history/history_screen.dart b/lib/modules/history/history_screen.dart index a6ea4535..483c0e4e 100644 --- a/lib/modules/history/history_screen.dart +++ b/lib/modules/history/history_screen.dart @@ -18,7 +18,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/utils/date.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; import 'package:mangayomi/modules/widgets/error_text.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; diff --git a/lib/modules/library/providers/library_state_provider.dart b/lib/modules/library/providers/library_state_provider.dart index 7c5c64dd..b7208189 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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.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/library/widgets/continue_reader_button.dart b/lib/modules/library/widgets/continue_reader_button.dart index df1945e2..9dfaa65d 100644 --- a/lib/modules/library/widgets/continue_reader_button.dart +++ b/lib/modules/library/widgets/continue_reader_button.dart @@ -6,7 +6,7 @@ import 'package:mangayomi/models/history.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; class ContinueReaderButton extends ConsumerWidget { final Manga entry; diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 5bc42e45..b573dfff 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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.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/manga_details_view.dart b/lib/modules/manga/detail/manga_details_view.dart index 0b18e47b..21ecf219 100644 --- a/lib/modules/manga/detail/manga_details_view.dart +++ b/lib/modules/manga/detail/manga_details_view.dart @@ -14,7 +14,7 @@ import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/modules/manga/detail/manga_detail_view.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; class MangaDetailsView extends ConsumerStatefulWidget { final Manga manga; diff --git a/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart b/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart index f8062a3e..211cc806 100644 --- a/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart +++ b/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart @@ -11,7 +11,7 @@ import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/date.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; import 'package:mangayomi/modules/manga/download/download_page_widget.dart'; diff --git a/lib/modules/manga/download/download_page_widget.dart b/lib/modules/manga/download/download_page_widget.dart index 63191441..38dbbcb7 100644 --- a/lib/modules/manga/download/download_page_widget.dart +++ b/lib/modules/manga/download/download_page_widget.dart @@ -9,7 +9,7 @@ import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/modules/manga/download/providers/download_provider.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; import 'package:share_plus/share_plus.dart'; diff --git a/lib/modules/manga/download/providers/download_provider.dart b/lib/modules/manga/download/providers/download_provider.dart index 806ed29d..9da1be81 100644 --- a/lib/modules/manga/download/providers/download_provider.dart +++ b/lib/modules/manga/download/providers/download_provider.dart @@ -26,7 +26,7 @@ import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/services/download_manager/m3u8/m3u8_downloader.dart'; import 'package:mangayomi/services/download_manager/m3u8/models/download.dart'; import 'package:mangayomi/utils/chapter_recognition.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; import 'package:mangayomi/utils/reg_exp_matcher.dart'; diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart index af1100a9..ce9ae8ed 100644 --- a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -4,7 +4,7 @@ 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'; +import 'package:mangayomi/utils/extensions/manga_extensions.dart'; /// Shared navigation and history logic used by [ReaderController], /// [NovelReaderController], and [AnimeStreamController]. diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 01900c29..1ca48912 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -7,7 +7,7 @@ import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.d import 'package:mangayomi/models/settings.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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'reader_controller_provider.g.dart'; 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 07236d9b..8e3cb5da 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/utils/extensions/manga.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.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/more/download_queue/download_queue_screen.dart b/lib/modules/more/download_queue/download_queue_screen.dart index b206181f..29ac083e 100644 --- a/lib/modules/more/download_queue/download_queue_screen.dart +++ b/lib/modules/more/download_queue/download_queue_screen.dart @@ -8,7 +8,7 @@ import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/modules/manga/detail/widgets/custom_floating_action_btn.dart'; import 'package:mangayomi/modules/manga/download/providers/download_provider.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; class DownloadQueueScreen extends ConsumerWidget { diff --git a/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart b/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart index fd73d74a..d5a5787d 100644 --- a/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart +++ b/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart @@ -7,7 +7,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/modules/manga/download/download_page_widget.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; class UpdateChapterListTileWidget extends ConsumerWidget { diff --git a/lib/utils/extensions/chapter.dart b/lib/utils/extensions/chapter_extensions.dart similarity index 98% rename from lib/utils/extensions/chapter.dart rename to lib/utils/extensions/chapter_extensions.dart index f469f97c..116d59c3 100644 --- a/lib/utils/extensions/chapter.dart +++ b/lib/utils/extensions/chapter_extensions.dart @@ -10,7 +10,7 @@ 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/utils/extensions/manga.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.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'; diff --git a/lib/utils/extensions/manga.dart b/lib/utils/extensions/manga_extensions.dart similarity index 100% rename from lib/utils/extensions/manga.dart rename to lib/utils/extensions/manga_extensions.dart From 8adb8bee173aeee26c8f0fc4cc5712ba9e27ff03 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:50:22 +0200 Subject: [PATCH 02/30] reduce memory usage `chapters.length - chapters.reversed.toList().indexOf(chapters.reversed.toList()[finalIndex]) - 1;` is just the same as `chapters.length - 1 - finalIndex` but it creates two lists, which is wasteful. --- lib/modules/manga/detail/manga_detail_view.dart | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index b573dfff..a1b95918 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -900,14 +900,8 @@ class _MangaDetailViewState extends ConsumerState chapterLength: chapters.length, ); } - int reverseIndex = - chapters.length - - chapters.reversed.toList().indexOf( - chapters.reversed.toList()[finalIndex], - ) - - 1; final indexx = reverse - ? reverseIndex + ? (chapters.length - 1 - finalIndex) : finalIndex; return ChapterListTileWidget( chapter: chapters[indexx], From 407751102219514ee72cf46380d90fe886dbd912 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:00:10 +0200 Subject: [PATCH 03/30] Improve chapter sorting & update logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ChapterRecognition for numeric chapter parsing - Replace old comparators with chapter‑number–aware sorting - Unify sort modes and simplify list handling - Rewrite updateMangaDetail with URL‑based dedupe - Preserve read state across scanlators - Update existing chapters instead of recreating - Only create Update entries for new unread chapters - Recompute smartUpdateDays using combined chapter list - Remove outdated reversed/index‑based logic --- .../manga/detail/manga_detail_view.dart | 61 +++--- .../update_manga_detail_providers.dart | 195 +++++++++++------- lib/utils/extensions/manga_extensions.dart | 24 ++- 3 files changed, 166 insertions(+), 114 deletions(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index a1b95918..3935081a 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -22,6 +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/utils/chapter_recognition.dart'; import 'package:mangayomi/utils/extensions/chapter_extensions.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'; @@ -201,8 +202,7 @@ class _MangaDetailViewState extends ConsumerState required int sortChapter, required List filterScanlator, }) { - List? chapterList; - chapterList = data + final chapterList = data .where( (element) => filterUnread == 1 ? element.isRead == false @@ -232,33 +232,38 @@ class _MangaDetailViewState extends ConsumerState }) .where((element) => !filterScanlator.contains(element.scanlator)) .toList(); - List chapters = sortChapter == 1 - ? 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!); - }); + + final recognition = ChapterRecognition(); + final mangaTitle = widget.manga!.name ?? ''; + int chapNum(Chapter c) => + recognition.parseChapterNumber(mangaTitle, c.name ?? ''); + + switch (sortChapter) { + case 0: // by scanlator, then chapter number + chapterList.sort((a, b) { + final s = (a.scanlator ?? '').compareTo(b.scanlator ?? ''); + if (s != 0) return s; + return chapNum(a).compareTo(chapNum(b)); + }); + break; + case 1: // by chapter number + chapterList.sort((a, b) => chapNum(a).compareTo(chapNum(b))); + break; + case 2: // by upload date + chapterList.sort((a, b) { + if (a.dateUpload == null || b.dateUpload == null) return 0; + return int.parse(a.dateUpload!).compareTo(int.parse(b.dateUpload!)); + }); + break; + case 3: // by name + chapterList.sort((a, b) { + if (a.name == null || b.name == null) return 0; + return a.name!.compareTo(b.name!); + }); + break; } - return chapterList; + + return chapterList.reversed.toList(); } Widget _buildWidget({ diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart index 5c07e139..43838334 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart @@ -1,5 +1,5 @@ import 'package:mangayomi/eval/model/m_bridge.dart'; -import 'package:mangayomi/eval/model/m_manga.dart'; +import 'package:mangayomi/utils/chapter_recognition.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/update.dart'; @@ -20,7 +20,12 @@ Future updateMangaDetail( }) async { try { final manga = isar.mangas.getSync(mangaId!); - if ((manga!.isLocalArchive ?? false) || + if (manga == null) return; + + // loadSync() so .isNotEmpty is reliable (IsarLinks are lazy by default). + manga.chapters.loadSync(); + + if ((manga.isLocalArchive ?? false) || (manga.chapters.isNotEmpty && isInit)) { return; } @@ -30,10 +35,10 @@ Future updateMangaDetail( manga.sourceId, installedOnly: true, ); - MManga getManga; + if (source == null) return; - getManga = await ref.read( - getDetailProvider(url: manga.link!, source: source!).future, + final getManga = await ref.read( + getDetailProvider(url: manga.link!, source: source).future, ); final genre = @@ -45,6 +50,8 @@ Future updateMangaDetail( []; final imgUrl = getManga.imageUrl.trimmedOrDefault(manga.imageUrl); + final now = DateTime.now().millisecondsSinceEpoch; + manga ..imageUrl = imgUrl == null ? null @@ -64,90 +71,123 @@ Future updateMangaDetail( ..source = manga.source ..lang = manga.lang ..itemType = source.itemType - ..lastUpdate = DateTime.now().millisecondsSinceEpoch - ..updatedAt = DateTime.now().millisecondsSinceEpoch; - final checkManga = isar.mangas.getSync(mangaId); - if (checkManga!.chapters.isNotEmpty && isInit) { - return; - } - isar.writeTxnSync(() { - final mangaId = isar.mangas.putSync(manga); - manga.lastUpdate = DateTime.now().millisecondsSinceEpoch; + ..lastUpdate = now + ..updatedAt = now; - List chapters = []; + final chaps = getManga.chapters; - final chaps = getManga.chapters; - if (chaps!.isNotEmpty && chaps.length > manga.chapters.length) { - int newChapsIndex = chaps.length - manga.chapters.length; - manga.lastUpdate = DateTime.now().millisecondsSinceEpoch; - for (var i = 0; i < newChapsIndex; i++) { - final chapter = Chapter( - name: chaps[i].name!, - url: chaps[i].url!.trim(), - dateUpload: chaps[i].dateUpload == null - ? DateTime.now().millisecondsSinceEpoch.toString() - : chaps[i].dateUpload.toString(), - scanlator: chaps[i].scanlator ?? '', - mangaId: mangaId, - updatedAt: DateTime.now().millisecondsSinceEpoch, - isFiller: chaps[i].isFiller, - thumbnailUrl: chaps[i].thumbnailUrl, - description: chaps[i].description, - downloadSize: chaps[i].downloadSize, - duration: chaps[i].duration, + await isar.writeTxn(() async { + // Persist updated manga metadata. + final savedMangaId = await isar.mangas.put(manga); + + if (chaps == null || chaps.isEmpty) return; + + // loadSync() was called before the transaction; the set is still valid + // here because we haven't written to chapters yet. + final existingChapters = manga.chapters.toList(); + final existingByUrl = { + for (final c in existingChapters) + if (c.url?.isNotEmpty == true) c.url!.trim(): c, + }; + + // Build a chapterNumber -> isRead map so that when a new scanlator covers + // a chapter the user has already read, the new entry is pre-marked read. + // The value is true if ANY existing chapter at that number is read. + final recognition = ChapterRecognition(); + final readByNumber = {}; + for (final c in existingChapters) { + if (c.name == null) continue; + final num = recognition.parseChapterNumber(manga.name!, c.name!); + if (num > 0) { + readByNumber[num] = + (readByNumber[num] ?? false) || (c.isRead ?? false); + } + } + + final newChapters = []; + + for (final chap in chaps) { + final url = chap.url!.trim(); + final existing = existingByUrl[url]; + + if (existing == null) { + // Determine whether this chapter number has already been read under + // a different scanlator, so we don't show it as unread to the user. + final chapNum = chap.name != null + ? recognition.parseChapterNumber(manga.name!, chap.name!) + : 0; + final alreadyRead = chapNum > 0 && (readByNumber[chapNum] ?? false); + + final newChapter = Chapter( + name: chap.name!, + url: url, + dateUpload: chap.dateUpload == null + ? now.toString() + : chap.dateUpload.toString(), + scanlator: chap.scanlator ?? '', + mangaId: savedMangaId, + updatedAt: now, + isFiller: chap.isFiller, + thumbnailUrl: chap.thumbnailUrl, + description: chap.description, + downloadSize: chap.downloadSize, + duration: chap.duration, )..manga.value = manga; - chapters.add(chapter); - } - } - if (chapters.isNotEmpty) { - for (var chap in chapters.reversed.toList()) { - isar.chapters.putSync(chap); - chap.manga.saveSync(); - if (manga.chapters.isNotEmpty) { - final update = Update( - mangaId: mangaId, - chapterName: chap.name, - date: DateTime.now().millisecondsSinceEpoch.toString(), - updatedAt: DateTime.now().millisecondsSinceEpoch, - )..chapter.value = chap; - isar.updates.putSync(update); - update.chapter.saveSync(); + + // Carry over read state if another scanlator's version was read. + if (alreadyRead) { + newChapter.isRead = alreadyRead; + newChapter.lastPageRead = "1"; } + + newChapters.add(newChapter); + } else { + // Existing chapter - refresh metadata only. + existing + ..name = chap.name + ..scanlator = chap.scanlator + ..updatedAt = now + ..isFiller = chap.isFiller + ..thumbnailUrl = chap.thumbnailUrl + ..description = chap.description + ..downloadSize = chap.downloadSize + ..duration = chap.duration; + await isar.chapters.put(existing); } } - final oldChapers = isar.mangas - .getSync(mangaId)! - .chapters - .toList() - .reversed - .toList(); - if (oldChapers.length == chaps.length) { - for (var i = 0; i < oldChapers.length; i++) { - final oldChap = oldChapers[i]; - final newChap = chaps[i]; - oldChap.name = newChap.name; - oldChap.url = newChap.url; - oldChap.scanlator = newChap.scanlator; - oldChap.updatedAt = DateTime.now().millisecondsSinceEpoch; - oldChap.isFiller = newChap.isFiller; - oldChap.thumbnailUrl = newChap.thumbnailUrl; - oldChap.description = newChap.description; - oldChap.downloadSize = newChap.downloadSize; - oldChap.duration = newChap.duration; - isar.chapters.putSync(oldChap); - oldChap.manga.saveSync(); + + // Insert new chapters oldest-first (API typically returns newest-first). + if (newChapters.isNotEmpty) { + final hasExisting = existingChapters.isNotEmpty; + for (final chap in newChapters.reversed) { + await isar.chapters.put(chap); + await chap.manga.save(); + + // Only create an Update entry for genuinely new (unread) chapters, + // so that pre-read cross-scanlator chapters don't spam the updates feed. + if (hasExisting && !(chap.isRead ?? false)) { + final update = Update( + mangaId: savedMangaId, + chapterName: chap.name, + date: now.toString(), + updatedAt: now, + )..chapter.value = chap; + await isar.updates.put(update); + await update.chapter.save(); + } } } // Calculate fetch interval: // median of gaps between recent distinct chapter dates, clamped [1, 28]. - final allChapters = isar.mangas.getSync(mangaId)!.chapters.toList(); + final allChapters = newChapters.isEmpty + ? existingChapters + : [...existingChapters, ...newChapters]; if (allChapters.isNotEmpty) { final interval = FetchInterval.calculateInterval(allChapters); - isar.mangas.putSync( - manga - ..id = mangaId - ..smartUpdateDays = interval, - ); + manga + ..id = savedMangaId + ..smartUpdateDays = interval; + await isar.mangas.put(manga); } }); } catch (e, s) { @@ -156,7 +196,6 @@ Future updateMangaDetail( } else { rethrow; } - return; } } diff --git a/lib/utils/extensions/manga_extensions.dart b/lib/utils/extensions/manga_extensions.dart index ba6e5ad7..bcaaa290 100644 --- a/lib/utils/extensions/manga_extensions.dart +++ b/lib/utils/extensions/manga_extensions.dart @@ -4,6 +4,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/utils/chapter_recognition.dart'; extension MangaExtensions on Manga { // ── For the READER: always ascending story order, filters applied ────────── @@ -89,19 +90,26 @@ extension MangaExtensions on Manga { // Start from the reading list so filter logic lives in one place. List list = getChapterListForReading(); + // Cache recognition instance — parseChapterNumber is called O(n log n) + // times during sort, so avoid constructing it inside the comparator. + final recognition = ChapterRecognition(); + final mangaTitle = name ?? ''; + + // Returns the parsed chapter number for a chapter, used as the primary + // numeric sort key for cases 0 and 1. + int chapNum(Chapter c) => + recognition.parseChapterNumber(mangaTitle, c.name ?? ''); + switch (sortIndex) { - case 0: // by scanlator, then date + case 0: // by scanlator, then chapter number list.sort((a, b) { - if (a.scanlator == null || b.scanlator == null) return 0; - final s = a.scanlator!.compareTo(b.scanlator!); + 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, - ); + return chapNum(a).compareTo(chapNum(b)); }); break; - case 1: // by chapter number - reading list is already ascending + case 1: // by chapter number + list.sort((a, b) => chapNum(a).compareTo(chapNum(b))); break; case 2: // by upload date list.sort((a, b) { From 518b484a697fedede1323843da5fa37b27315b18 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:21:41 +0200 Subject: [PATCH 04/30] Add Copilot suggested change (1/3) https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3139743002 --- .../manga/detail/providers/update_manga_detail_providers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart index 43838334..4ba638fa 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart @@ -97,7 +97,7 @@ Future updateMangaDetail( final readByNumber = {}; for (final c in existingChapters) { if (c.name == null) continue; - final num = recognition.parseChapterNumber(manga.name!, c.name!); + final num = recognition.parseChapterNumber(manga.name ?? '', c.name!); if (num > 0) { readByNumber[num] = (readByNumber[num] ?? false) || (c.isRead ?? false); From 519eb9d58903081d54d00a1eb2befd8646002d4d Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:22:56 +0200 Subject: [PATCH 05/30] Add Copilot suggested change (2/3) https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3139743018 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../manga/detail/providers/update_manga_detail_providers.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart index 4ba638fa..b56fba4b 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart @@ -107,7 +107,8 @@ Future updateMangaDetail( final newChapters = []; for (final chap in chaps) { - final url = chap.url!.trim(); + final url = chap.url?.trim(); + if (url == null || url.isEmpty) continue; final existing = existingByUrl[url]; if (existing == null) { From 3434a2b16e5a7b67a813f962bc997182e919d3d0 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:23:57 +0200 Subject: [PATCH 06/30] Add Copilot suggested change (3/3) https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3139743045 --- lib/modules/manga/detail/manga_detail_view.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 3935081a..11d692c6 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -251,8 +251,9 @@ class _MangaDetailViewState extends ConsumerState break; case 2: // by upload date chapterList.sort((a, b) { - if (a.dateUpload == null || b.dateUpload == null) return 0; - return int.parse(a.dateUpload!).compareTo(int.parse(b.dateUpload!)); + return (int.tryParse(a.dateUpload ?? '') ?? 0).compareTo( + int.tryParse(b.dateUpload ?? '') ?? 0, + ); }); break; case 3: // by name From 8ac25750a24a38510bbdb118d2ac831ff81e9545 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:15:13 +0200 Subject: [PATCH 07/30] Why reverse the reading list and then calculate -1 --- lib/modules/manga/reader/mixins/chapter_controller_mixin.dart | 4 ++-- lib/utils/extensions/manga_extensions.dart | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart index ce9ae8ed..9be8e5a4 100644 --- a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -44,8 +44,8 @@ mixin ChapterControllerMixin { // --------------------------------------------------------------------------- (int, bool) getChapterIndex() => _chapterIndexWithOffset(0); - Chapter getPrevChapter() => _chapterWithOffset(1); - Chapter getNextChapter() => _chapterWithOffset(-1); + 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 diff --git a/lib/utils/extensions/manga_extensions.dart b/lib/utils/extensions/manga_extensions.dart index bcaaa290..31d2b18b 100644 --- a/lib/utils/extensions/manga_extensions.dart +++ b/lib/utils/extensions/manga_extensions.dart @@ -72,8 +72,6 @@ extension MangaExtensions on Manga { return filterDownloaded == 1 ? dl : !dl; }) .where((e) => !filterScanlator.contains(e.scanlator)) - .toList() - .reversed .toList(); } From 29f202d31d9743dd7a1fd5ccd3e213a35ddeb1f6 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:41:40 +0200 Subject: [PATCH 08/30] remove redundant methods `_getFilteredAndSortedChapters()` and `_filterAndSortChapter()` are duplicates of `getFilteredChapterList()` from MangaExtensions. --- .../manga/detail/manga_detail_view.dart | 179 +++--------------- 1 file changed, 26 insertions(+), 153 deletions(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 11d692c6..6ca32f5c 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/utils/chapter_recognition.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.dart'; import 'package:mangayomi/utils/extensions/chapter_extensions.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'; @@ -108,22 +108,14 @@ class _MangaDetailViewState extends ConsumerState late final isLocalArchive = widget.manga!.isLocalArchive ?? false; @override Widget build(BuildContext context) { - final scanlators = ref.watch(scanlatorsFilterStateProvider(widget.manga!)); - final reverse = ref - .watch(sortChapterStateProvider(mangaId: widget.manga!.id!)) - .reverse!; - final filterUnread = ref.watch( - chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!), - ); - final filterBookmarked = ref.watch( - chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!), - ); - final filterDownloaded = ref.watch( - chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!), - ); - final sortChapter = - ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)).index - as int; + // Watch all sort/filter providers so the list rebuilds whenever + // the user changes settings in _showDraggableMenu(). + ref.watch(scanlatorsFilterStateProvider(widget.manga!)); + ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!)); + ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)); final chapters = ref.watch( getChaptersStreamProvider(mangaId: widget.manga!.id!), ); @@ -139,138 +131,21 @@ class _MangaDetailViewState extends ConsumerState }, child: chapters.when( data: (data) { - List chapters = _filterAndSortChapter( - data: data.reversed.toList(), - filterUnread: filterUnread, - filterBookmarked: filterBookmarked, - filterDownloaded: filterDownloaded, - sortChapter: sortChapter, - filterScanlator: scanlators.$2, - ); + List chapters = widget.manga!.getFilteredChapterList(); ref.read(chaptersListttStateProvider.notifier).set(chapters); - return _buildWidget(chapters: chapters, reverse: reverse); + return _buildWidget(chapters: chapters); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { - return _buildWidget( - chapters: widget.manga!.chapters.toList().reversed.toList(), - reverse: reverse, - ); + return _buildWidget(chapters: widget.manga!.chapters.toList()); }, ), ); } - List _getFilteredAndSortedChapters() { - final filterScanlator = ref.read( - scanlatorsFilterStateProvider(widget.manga!), - ); - final filterUnread = ref.read( - chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!), - ); - final filterBookmarked = ref.read( - chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!), - ); - final filterDownloaded = ref.read( - chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!), - ); - final sortChapter = - ref.read(sortChapterStateProvider(mangaId: widget.manga!.id!)).index - as int; - final chapters = isar.chapters - .filter() - .idIsNotNull() - .mangaIdEqualTo(widget.manga!.id!) - .findAllSync(); - return _filterAndSortChapter( - data: chapters, - filterUnread: filterUnread, - filterBookmarked: filterBookmarked, - filterDownloaded: filterDownloaded, - sortChapter: sortChapter, - filterScanlator: filterScanlator.$2, - ); - } - - List _filterAndSortChapter({ - required List data, - required int filterUnread, - required int filterBookmarked, - required int filterDownloaded, - required int sortChapter, - required List filterScanlator, - }) { - final 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) { - final modelChapDownload = isar.downloads - .filter() - .idEqualTo(element.id) - .findAllSync(); - return filterDownloaded == 1 - ? modelChapDownload.isNotEmpty && - modelChapDownload.first.isDownload == true - : filterDownloaded == 2 - ? !(modelChapDownload.isNotEmpty && - modelChapDownload.first.isDownload == true) - : true; - }) - .where((element) => !filterScanlator.contains(element.scanlator)) - .toList(); - - final recognition = ChapterRecognition(); - final mangaTitle = widget.manga!.name ?? ''; - int chapNum(Chapter c) => - recognition.parseChapterNumber(mangaTitle, c.name ?? ''); - - switch (sortChapter) { - case 0: // by scanlator, then chapter number - chapterList.sort((a, b) { - final s = (a.scanlator ?? '').compareTo(b.scanlator ?? ''); - if (s != 0) return s; - return chapNum(a).compareTo(chapNum(b)); - }); - break; - case 1: // by chapter number - chapterList.sort((a, b) => chapNum(a).compareTo(chapNum(b))); - break; - case 2: // by upload date - chapterList.sort((a, b) { - return (int.tryParse(a.dateUpload ?? '') ?? 0).compareTo( - int.tryParse(b.dateUpload ?? '') ?? 0, - ); - }); - break; - case 3: // by name - chapterList.sort((a, b) { - if (a.name == null || b.name == null) return 0; - return a.name!.compareTo(b.name!); - }); - break; - } - - return chapterList.reversed.toList(); - } - - Widget _buildWidget({ - required List chapters, - required bool reverse, - }) { + Widget _buildWidget({required List chapters}) { final chapterList = ref.watch(chaptersListStateProvider); final isLongPressed = ref.watch(isLongPressedStateProvider); final checkCategoryList = isar.categorys @@ -498,8 +373,8 @@ class _MangaDetailViewState extends ConsumerState ]; }, onSelected: (value) { - final chapters = - _getFilteredAndSortedChapters(); + final chapters = widget.manga! + .getFilteredChapterList(); if (value == 0 || value == 1 || value == 2 || @@ -555,13 +430,13 @@ class _MangaDetailViewState extends ConsumerState ref.watch(processDownloadsProvider()); } } else if (value == 4) { - final List unreadChapters = - _getFilteredAndSortedChapters() - .where( - (element) => - !(element.isRead ?? false), - ) - .toList(); + final List unreadChapters = widget + .manga! + .getFilteredChapterList() + .where( + (element) => !(element.isRead ?? false), + ) + .toList(); isar.chapters .filter() .idIsNotNull() @@ -583,8 +458,9 @@ class _MangaDetailViewState extends ConsumerState } ref.watch(processDownloadsProvider()); } else if (value == 5) { - final List allChapters = - _getFilteredAndSortedChapters(); + final List allChapters = widget + .manga! + .getFilteredChapterList(); for (var chapter in allChapters) { final entry = isar.downloads .filter() @@ -906,11 +782,8 @@ class _MangaDetailViewState extends ConsumerState chapterLength: chapters.length, ); } - final indexx = reverse - ? (chapters.length - 1 - finalIndex) - : finalIndex; return ChapterListTileWidget( - chapter: chapters[indexx], + chapter: chapters[finalIndex], chapterList: chapterList, allChapters: chapters, sourceExist: widget.sourceExist, From 085c731bcea5b1b1adc783e19145447c4547dc89 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:09:28 +0200 Subject: [PATCH 09/30] Make chapter UI list descending by default - remove the reverse parameter because false is already the model default, so passing it is redundant. - flip the reverse bool, to keep the chapter sorting of already added manga the same. Otherwise the user would have to change the sorting orientation for the chapters in the library. --- lib/utils/extensions/manga_extensions.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/extensions/manga_extensions.dart b/lib/utils/extensions/manga_extensions.dart index 31d2b18b..1041c737 100644 --- a/lib/utils/extensions/manga_extensions.dart +++ b/lib/utils/extensions/manga_extensions.dart @@ -81,9 +81,9 @@ extension MangaExtensions on Manga { final sortChapterEntry = settings.sortChapterList!.where((e) => e.mangaId == id).firstOrNull ?? - SortChapter(mangaId: id, index: 1, reverse: false); + SortChapter(mangaId: id, index: 1); final sortIndex = sortChapterEntry.index!; - final reverse = sortChapterEntry.reverse!; + final reverse = !sortChapterEntry.reverse!; // Start from the reading list so filter logic lives in one place. List list = getChapterListForReading(); From a9e307b2a42c4aefa6e6300c34d0427d5d5bc8e6 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:05:21 +0200 Subject: [PATCH 10/30] Add multi-season support and split parse methods Changes: - Add season-keyword regex (staffel, season, saison, temporada) and episode-keyword regex (folge, episode, ep.) to reliably extract the correct number regardless of name format - parseChapterNumber() now encodes season context into the sort key (season * 100000 + episode) so multi-season anime sort correctly across seasons without mixing episode numbers - Add parseEpisodeNumber() which strips season context and returns only the episode number within a season; use this for tracker updates (MAL/AniList/Kitsu) and AniSkip lookups, where the tracker entry is already season-specific - Switch updateTrackChapterRead and getAniSkipResults to parseEpisodeNumber to fix incorrect episode reporting for multi-season anime - Compile all RegExp objects as static finals instead of per-call instantiation - Refactor duplicated parse logic into a single private _parse() method with an applySeason flag --- .../anime_player_controller_provider.dart | 2 +- lib/utils/chapter_recognition.dart | 97 ++++++++++++------- lib/utils/extensions/chapter_extensions.dart | 2 +- 3 files changed, 66 insertions(+), 35 deletions(-) diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index f8558100..8c9d0fd3 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -127,7 +127,7 @@ class AnimeStreamController extends _$AnimeStreamController .read(aniSkipProvider.notifier) .getResult( id, - ChapterRecognition().parseChapterNumber( + ChapterRecognition().parseEpisodeNumber( episode.manga.value!.name!, episode.name!, ), diff --git a/lib/utils/chapter_recognition.dart b/lib/utils/chapter_recognition.dart index c874bb96..f7bebfe4 100644 --- a/lib/utils/chapter_recognition.dart +++ b/lib/utils/chapter_recognition.dart @@ -1,50 +1,81 @@ class ChapterRecognition { - final _numberPattern = r"([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"; - - final _unwanted = RegExp( - r"\b(?:v|ver|vol|version|volume|season|s)[^a-z]?[0-9]+", + static final _unwanted = RegExp( + r"\b(?:v|ver|vol|version|volume|season|staffel|saison|temporada|s)[^a-z]?[0-9]+", ); + static final _unwantedWhiteSpace = RegExp(r"\s(?=extra|special|omake)"); + static final _seasonKeyword = RegExp( + r"\b(?:staffel|season|saison|temporada)\s*([0-9]+)", + ); + static final _episodeKeyword = RegExp( + r"\b(?:folge|episode|ep\.?)\s*([0-9]+(?:\.[0-9]+)?)", + ); + // lookbehind for "ch." then zero or more spaces. + static final _chNotation = RegExp( + r"(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?", + ); + static final _bareNumber = RegExp(r"([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"); - final _unwantedWhiteSpace = RegExp(r"\s(?=extra|special|omake)"); + /// Sort key for the UI list. Encodes season into the key so multi-season + /// anime sort correctly: key = season * 100000 + episode. + int parseChapterNumber(String mangaTitle, String chapterName) => + _parse(mangaTitle, chapterName, applySeason: true); - int parseChapterNumber(String mangaTitle, String chapterName) { - var name = chapterName.toLowerCase(); + /// Episode number within a season, for tracker updates (MAL/AniList/Kitsu) + /// and AniSkip results. The tracker entry is already season-specific, + /// so season is stripped. + int parseEpisodeNumber(String mangaTitle, String chapterName) => + _parse(mangaTitle, chapterName, applySeason: false); - name = name.replaceAll(mangaTitle.toLowerCase(), "").trim(); + int _parse( + String mangaTitle, + String chapterName, { + required bool applySeason, + }) { + // Normalize the chapter name by removing title, punctuation noise, etc. + final name = chapterName + .toLowerCase() + .replaceAll(mangaTitle.toLowerCase(), '') + .trim() + .replaceAll(',', '.') + .replaceAll('-', '.') + .replaceAll(_unwantedWhiteSpace, ''); - name = name.replaceAll(',', '.').replaceAll('-', '.'); + final season = applySeason + ? int.tryParse(_seasonKeyword.firstMatch(name)?.group(1) ?? '') ?? 0 + : 0; - name = name.replaceAll(_unwantedWhiteSpace, ""); - - name = name.replaceAll(_unwanted, ""); - final numberPat = "*$_numberPattern"; - const ch = r"(?<=ch\.)"; - var match = RegExp("$ch $numberPat").firstMatch(name); - if (match != null) { - return _getChapterNumberFromMatch(match).toInt(); + final epMatch = _episodeKeyword.firstMatch(name); + if (epMatch != null) { + final ep = double.parse(epMatch.group(1)!).toInt(); + return _withSeason(season, ep); } - match = RegExp(_numberPattern).firstMatch(name); - if (match != null) { - return _getChapterNumberFromMatch(match).toInt(); - } - - return 0; + // strip season/volume noise, then look for ch. or bare number. + final stripped = name.replaceAll(_unwanted, ''); + final ep = _extractNumber(stripped); + return ep != null ? _withSeason(season, ep) : 0; } - double _getChapterNumberFromMatch(Match match) { - final initial = double.parse(match.group(1)!); - final subChapterDecimal = match.group(2); - final subChapterAlpha = match.group(3); - final addition = _checkForDecimal(subChapterDecimal, subChapterAlpha); - return initial + addition; + // Combines season + episode into a sortable integer. + int _withSeason(int season, int ep) => season > 0 ? season * 100000 + ep : ep; + + int? _extractNumber(String name) { + final chMatch = _chNotation.firstMatch(name); + if (chMatch != null) return _fromMatch(chMatch).toInt(); + + final numMatch = _bareNumber.firstMatch(name); + if (numMatch != null) return _fromMatch(numMatch).toInt(); + + return null; } - double _checkForDecimal(String? decimal, String? alpha) { - if (decimal != null && decimal.isNotEmpty) { - return double.parse(decimal); - } + double _fromMatch(Match match) { + final base = double.parse(match.group(1)!); + return base + _decimalAddition(match.group(2), match.group(3)); + } + double _decimalAddition(String? decimal, String? alpha) { + if (decimal != null && decimal.isNotEmpty) return double.parse(decimal); if (alpha != null && alpha.isNotEmpty) { if (alpha.contains("extra")) { return 0.99; diff --git a/lib/utils/extensions/chapter_extensions.dart b/lib/utils/extensions/chapter_extensions.dart index 116d59c3..db4d71ba 100644 --- a/lib/utils/extensions/chapter_extensions.dart +++ b/lib/utils/extensions/chapter_extensions.dart @@ -96,7 +96,7 @@ extension ChapterExtension on Chapter { ); if (!updateProgressAfterReading) return; final manga = this.manga.value!; - final chapterNumber = ChapterRecognition().parseChapterNumber( + final chapterNumber = ChapterRecognition().parseEpisodeNumber( manga.name!, name!, ); From d625b9c77d0084d85204549c709562e66353fb7d Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:08:09 +0200 Subject: [PATCH 11/30] Improve EndOfMangaCard When in vertical mode, it now shows the last_page icon pointing down, --- .../reader/widgets/chapter_transition_page.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/modules/manga/reader/widgets/chapter_transition_page.dart b/lib/modules/manga/reader/widgets/chapter_transition_page.dart index f6167e64..6a48b071 100644 --- a/lib/modules/manga/reader/widgets/chapter_transition_page.dart +++ b/lib/modules/manga/reader/widgets/chapter_transition_page.dart @@ -315,10 +315,17 @@ class ChapterTransitionPage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.last_page, - size: 24, - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + RotatedBox( + quarterTurns: _isVertical + ? 1 // turn 90° clockwise, so Icon is pointing down + : _isRTL + ? 2 // turn 180°, so Icon is pointing left + : 0, // no rotation, Icon points to the right. + child: Icon( + Icons.last_page, + size: 24, + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), ), const SizedBox(height: 6), Text( From b05c17518f525f661515f29cddcc481c850edfed Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:03:56 +0200 Subject: [PATCH 12/30] Reduce Code Duplication Across 3 Files By extracting: ``` SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: SystemUiOverlay.values, ); ``` to a file `system_ui.dart` and calling the method `restoreSystemUI()` --- lib/modules/anime/anime_player_view.dart | 21 +++++---------------- lib/modules/manga/reader/reader_view.dart | 21 +++++---------------- lib/modules/novel/novel_reader_view.dart | 21 +++++---------------- lib/utils/system_ui.dart | 6 ++++++ 4 files changed, 21 insertions(+), 48 deletions(-) create mode 100644 lib/utils/system_ui.dart diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index acb74d19..ede9fe14 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -43,6 +43,7 @@ import 'package:mangayomi/services/get_video_list.dart'; import 'package:mangayomi/services/torrent_server.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/language.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit/generated/libmpv/bindings.dart' as generated; import 'package:media_kit_video/media_kit_video.dart'; @@ -78,10 +79,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { for (var infoHash in _infoHashList) { MTorrentServer().removeTorrent(infoHash); } - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); super.dispose(); } @@ -129,10 +127,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { title: const Text(''), leading: BackButton( onPressed: () { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); }, ), @@ -148,10 +143,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { leading: BackButton( color: Colors.white, onPressed: () { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); }, ), @@ -1940,10 +1932,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ref.read(fullscreenProvider.notifier).state = !fullScreen; widget.desktopFullScreenPlayer.call(!fullScreen); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } if (mounted) { // Set variable to true, so the player uses the global diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index ac8d748c..de01f52c 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -37,6 +37,7 @@ import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart'; import 'package:mangayomi/modules/manga/reader/image_view_webtoon.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:photo_view/photo_view.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -102,10 +103,7 @@ class _MangaReaderViewState extends ConsumerState { leading: BackButton( onPressed: () { if (restoreUi) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } Navigator.of(context).pop(); }, @@ -189,10 +187,7 @@ class _MangaChapterPageGalleryState } else if (isDesktop) { setFullScreen(value: false); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } discordRpc?.showIdleText(); final actualIdx = _pageViewToActualIndexSync(_currentIndex!); @@ -1467,10 +1462,7 @@ class _MangaChapterPageGalleryState } void _goBack(BuildContext context) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); } @@ -1483,10 +1475,7 @@ class _MangaChapterPageGalleryState } if (fullScreenReader) { if (_isView) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index 95081270..a2342e7e 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -24,6 +24,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/get_html_content.dart'; import 'package:mangayomi/src/rust/api/epub.dart'; import 'package:mangayomi/utils/extensions/dom_extensions.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:mangayomi/utils/utils.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; @@ -108,10 +109,7 @@ class _NovelWebViewState extends ConsumerState if (isDesktop) { setFullScreen(value: false); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } discordRpc?.showIdleText(); super.dispose(); @@ -840,10 +838,7 @@ class _NovelWebViewState extends ConsumerState leading: BackButton( onPressed: () { if (restoreUi) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } Navigator.of(context).pop(); }, @@ -854,10 +849,7 @@ class _NovelWebViewState extends ConsumerState } void _goBack(BuildContext context) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); } @@ -1414,10 +1406,7 @@ class _NovelWebViewState extends ConsumerState } if (fullScreenReader) { if (_isView) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } diff --git a/lib/utils/system_ui.dart b/lib/utils/system_ui.dart new file mode 100644 index 00000000..2cc9729d --- /dev/null +++ b/lib/utils/system_ui.dart @@ -0,0 +1,6 @@ +import 'package:flutter/services.dart'; + +void restoreSystemUI() => SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, +); From cc189fd4e35c270d20f390fea127a35b6b8894f7 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:05:23 +0200 Subject: [PATCH 13/30] Reduce Code Duplication in anime_player_view.dart by extracting the same MPV Event Handler Boilerplate into a helper method. --- lib/modules/anime/anime_player_view.dart | 443 +++++++++++------------ 1 file changed, 209 insertions(+), 234 deletions(-) diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index ede9fe14..16dec8b5 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -354,6 +354,12 @@ class _AnimeStreamPageState extends riv.ConsumerState } } + String? _readMpvString(Pointer value) { + if (value.ref.format != generated.mpv_format.MPV_FORMAT_STRING) return null; + final text = value.ref.u.string.cast().toDartString(); + return text.isEmpty ? null : text; + } + Future _handleMpvNodeEvents( String propName, Pointer value, @@ -361,272 +367,243 @@ class _AnimeStreamPageState extends riv.ConsumerState final nativePlayer = _player.platform as NativePlayer; switch (propName.substring(10)) { case "aniyomi/show_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - botToast( - text, - alignY: -0.99, - second: 2, - dismissDirections: const [ - DismissDirection.vertical, - DismissDirection.horizontal, - ], - showIcon: false, - ); - nativePlayer.setProperty("user-data/aniyomi/show_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + botToast( + text, + alignY: -0.99, + second: 2, + dismissDirections: const [ + DismissDirection.vertical, + DismissDirection.horizontal, + ], + showIcon: false, + ); + nativePlayer.setProperty("user-data/aniyomi/show_text", ""); break; case "aniyomi/toggle_ui": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "show": - break; - case "hide": - break; - case "toggle": - break; - } - nativePlayer.setProperty("user-data/aniyomi/toggle_ui", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "show": + break; + case "hide": + break; + case "toggle": + break; } + nativePlayer.setProperty("user-data/aniyomi/toggle_ui", ""); break; case "aniyomi/show_panel": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "subtitle_settings": - break; - case "subtitle_delay": - break; - case "audio_delay": - break; - case "video_filters": - break; - } - nativePlayer.setProperty("user-data/aniyomi/show_panel", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "subtitle_settings": + break; + case "subtitle_delay": + break; + case "audio_delay": + break; + case "video_filters": + break; } + nativePlayer.setProperty("user-data/aniyomi/show_panel", ""); break; case "aniyomi/software_keyboard": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "show": - break; - case "hide": - break; - case "toggle": - break; - } - nativePlayer.setProperty("user-data/aniyomi/software_keyboard", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "show": + break; + case "hide": + break; + case "toggle": + break; } + nativePlayer.setProperty("user-data/aniyomi/software_keyboard", ""); break; case "aniyomi/set_button_title": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - _customButton.value = temp..currentTitle = text; - nativePlayer.setProperty("user-data/aniyomi/set_button_title", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + _customButton.value = temp..currentTitle = text; + nativePlayer.setProperty("user-data/aniyomi/set_button_title", ""); break; case "aniyomi/reset_button_title": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - _customButton.value = temp..currentTitle = temp.button.title ?? ""; - nativePlayer.setProperty("user-data/aniyomi/reset_button_title", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + _customButton.value = temp..currentTitle = temp.button.title ?? ""; + nativePlayer.setProperty("user-data/aniyomi/reset_button_title", ""); break; case "aniyomi/toggle_button": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - switch (text) { - case "show": - _customButton.value = temp..visible = true; - break; - case "hide": - _customButton.value = temp..visible = false; - break; - case "toggle": - _customButton.value = temp..visible = !temp.visible; - break; - } - nativePlayer.setProperty("user-data/aniyomi/toggle_button", ""); + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + switch (text) { + case "show": + _customButton.value = temp..visible = true; + break; + case "hide": + _customButton.value = temp..visible = false; + break; + case "toggle": + _customButton.value = temp..visible = !temp.visible; + break; } + nativePlayer.setProperty("user-data/aniyomi/toggle_button", ""); break; case "aniyomi/switch_episode": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - case "n": - pushToNewEpisode(context, _streamController.getNextEpisode()); - break; - case "p": - pushToNewEpisode(context, _streamController.getPrevEpisode()); - break; - } - nativePlayer.setProperty("user-data/aniyomi/switch_episode", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + case "n": + pushToNewEpisode(context, _streamController.getNextEpisode()); + break; + case "p": + pushToNewEpisode(context, _streamController.getPrevEpisode()); + break; } + nativePlayer.setProperty("user-data/aniyomi/switch_episode", ""); break; case "aniyomi/pause": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - case "pause": - await _player.pause(); - break; - case "unpause": - await _player.play(); - break; - case "pauseunpause": - await _player.playOrPause(); - break; - } - nativePlayer.setProperty("user-data/aniyomi/pause", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + case "pause": + await _player.pause(); + break; + case "unpause": + await _player.play(); + break; + case "pauseunpause": + await _player.playOrPause(); + break; } + nativePlayer.setProperty("user-data/aniyomi/pause", ""); break; case "aniyomi/seek_by": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = int.parse(text.replaceAll("\"", "")); - final pos = _currentPosition.value.inSeconds + data; - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - nativePlayer.setProperty("user-data/aniyomi/seek_by", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = int.parse(text.replaceAll("\"", "")); + final pos = _currentPosition.value.inSeconds + data; + _tempPosition.value = Duration(seconds: pos); + await _player.seek(Duration(seconds: pos)); + _tempPosition.value = null; + nativePlayer.setProperty("user-data/aniyomi/seek_by", ""); break; case "aniyomi/seek_to": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = int.parse(text.replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: data); - await _player.seek(Duration(seconds: data)); - _tempPosition.value = null; - nativePlayer.setProperty("user-data/aniyomi/seek_to", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = int.parse(text.replaceAll("\"", "")); + _tempPosition.value = Duration(seconds: data); + await _player.seek(Duration(seconds: data)); + _tempPosition.value = null; + nativePlayer.setProperty("user-data/aniyomi/seek_to", ""); break; case "aniyomi/seek_by_with_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final pos = - _currentPosition.value.inSeconds + - int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - (_player.platform as NativePlayer).command(["show-text", data[1]]); - nativePlayer.setProperty("user-data/aniyomi/seek_by_with_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + final pos = + _currentPosition.value.inSeconds + + int.parse(data[0].replaceAll("\"", "")); + _tempPosition.value = Duration(seconds: pos); + await _player.seek(Duration(seconds: pos)); + _tempPosition.value = null; + (_player.platform as NativePlayer).command(["show-text", data[1]]); + nativePlayer.setProperty("user-data/aniyomi/seek_by_with_text", ""); break; case "aniyomi/seek_to_with_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final pos = int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - (_player.platform as NativePlayer).command(["show-text", data[1]]); - nativePlayer.setProperty("user-data/aniyomi/seek_to_with_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + final pos = int.parse(data[0].replaceAll("\"", "")); + _tempPosition.value = Duration(seconds: pos); + await _player.seek(Duration(seconds: pos)); + _tempPosition.value = null; + (_player.platform as NativePlayer).command(["show-text", data[1]]); + nativePlayer.setProperty("user-data/aniyomi/seek_to_with_text", ""); break; case "aniyomi/launch_int_picker": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final start = int.parse(data[2]); - final stop = int.parse(data[3]); - final step = int.parse(data[4]); - int currentValue = start; - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(data[0]), - content: StatefulBuilder( - builder: (context, setState) => SizedBox( - height: 200, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NumberPicker( - value: currentValue, - minValue: start, - maxValue: stop, - step: step, - haptics: true, - textMapper: (numberText) => - data[1].replaceAll("%d", numberText), - onChanged: (value) => - setState(() => currentValue = value), - ), - ], - ), - ), - ), - actions: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + final start = int.parse(data[2]); + final stop = int.parse(data[3]); + final step = int.parse(data[4]); + int currentValue = start; + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(data[0]), + content: StatefulBuilder( + builder: (context, setState) => SizedBox( + height: 200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton( - onPressed: () async { - Navigator.pop(context); - }, - child: Text( - context.l10n.cancel, - style: TextStyle(color: context.primaryColor), - ), - ), - TextButton( - onPressed: () async { - final namePtr = data[5].toNativeUtf8(); - final valuePtr = calloc(1) - ..value = currentValue; - nativePlayer.mpv.mpv_set_property( - nativePlayer.ctx, - namePtr.cast(), - generated.mpv_format.MPV_FORMAT_INT64, - valuePtr.cast(), - ); - malloc.free(namePtr); - malloc.free(valuePtr); - Navigator.pop(context); - }, - child: Text( - context.l10n.ok, - style: TextStyle(color: context.primaryColor), - ), + NumberPicker( + value: currentValue, + minValue: start, + maxValue: stop, + step: step, + haptics: true, + textMapper: (numberText) => + data[1].replaceAll("%d", numberText), + onChanged: (value) => + setState(() => currentValue = value), ), ], ), - ], - ); - }, - ); - nativePlayer.setProperty("user-data/aniyomi/launch_int_picker", ""); - } + ), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () async { + Navigator.pop(context); + }, + child: Text( + context.l10n.cancel, + style: TextStyle(color: context.primaryColor), + ), + ), + TextButton( + onPressed: () async { + final namePtr = data[5].toNativeUtf8(); + final valuePtr = calloc(1)..value = currentValue; + nativePlayer.mpv.mpv_set_property( + nativePlayer.ctx, + namePtr.cast(), + generated.mpv_format.MPV_FORMAT_INT64, + valuePtr.cast(), + ); + malloc.free(namePtr); + malloc.free(valuePtr); + Navigator.pop(context); + }, + child: Text( + context.l10n.ok, + style: TextStyle(color: context.primaryColor), + ), + ), + ], + ), + ], + ); + }, + ); + nativePlayer.setProperty("user-data/aniyomi/launch_int_picker", ""); break; case "mangayomi/chapter_titles": if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { @@ -645,10 +622,8 @@ class _AnimeStreamPageState extends riv.ConsumerState } break; case "mangayomi/selected_shader": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - _selectedShader.value = text; - } + final text = _readMpvString(value); + _selectedShader.value = text ?? ''; break; } } From 47f3296e9e07f1d8d4a89c53ea30da1bca70854c Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:27:37 +0200 Subject: [PATCH 14/30] Reduce Code Duplication in anime_player_view.dart by extracting the helper methods `_seekTo()` and `_seekBy()`. This also fixes a potential bug, where in line 1634 it was calculated `skipDuration - _currentPosition.value.inSeconds` instead of the other way around. That doesn't make sense. If currentPosition = 120 and skipDuration = 10, this becomes: `_tempPosition = Duration(seconds: 10 - 120)`; so `= Duration(seconds: -110)` A negative duration makes no sense as a UI indicator of a seek target. --- lib/modules/anime/anime_player_view.dart | 76 ++++++------------------ 1 file changed, 18 insertions(+), 58 deletions(-) diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 16dec8b5..b613109c 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -360,6 +360,17 @@ class _AnimeStreamPageState extends riv.ConsumerState return text.isEmpty ? null : text; } + Future _seekTo(int absoluteSeconds) async { + _tempPosition.value = Duration(seconds: absoluteSeconds); + await _player.seek(Duration(seconds: absoluteSeconds)); + _tempPosition.value = null; + } + + Future _seekBy(int deltaSeconds) async { + final pos = _currentPosition.value.inSeconds + deltaSeconds; + await _seekTo(pos); + } + Future _handleMpvNodeEvents( String propName, Pointer value, @@ -492,31 +503,21 @@ class _AnimeStreamPageState extends riv.ConsumerState final text = _readMpvString(value); if (text == null) break; final data = int.parse(text.replaceAll("\"", "")); - final pos = _currentPosition.value.inSeconds + data; - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; + await _seekBy(data); nativePlayer.setProperty("user-data/aniyomi/seek_by", ""); break; case "aniyomi/seek_to": final text = _readMpvString(value); if (text == null) break; final data = int.parse(text.replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: data); - await _player.seek(Duration(seconds: data)); - _tempPosition.value = null; + await _seekTo(data); nativePlayer.setProperty("user-data/aniyomi/seek_to", ""); break; case "aniyomi/seek_by_with_text": final text = _readMpvString(value); if (text == null) break; final data = text.split("|"); - final pos = - _currentPosition.value.inSeconds + - int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; + await _seekBy(int.parse(data[0].replaceAll("\"", ""))); (_player.platform as NativePlayer).command(["show-text", data[1]]); nativePlayer.setProperty("user-data/aniyomi/seek_by_with_text", ""); break; @@ -524,10 +525,7 @@ class _AnimeStreamPageState extends riv.ConsumerState final text = _readMpvString(value); if (text == null) break; final data = text.split("|"); - final pos = int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; + await _seekTo(int.parse(data[0].replaceAll("\"", ""))); (_player.platform as NativePlayer).command(["show-text", data[1]]); nativePlayer.setProperty("user-data/aniyomi/seek_to_with_text", ""); break; @@ -1491,21 +1489,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ? ElevatedButton( onPressed: value?.onPress ?? - () async { - _tempPosition.value = Duration( - seconds: - defaultSkipIntroLength + - _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds + - defaultSkipIntroLength, - ), - ); - _tempPosition.value = null; - }, + () async => await _seekBy(defaultSkipIntroLength), onLongPress: value?.onLongPress, child: Padding( padding: const EdgeInsets.all(8.0), @@ -1628,19 +1612,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo height: 50, width: 50, child: IconButton( - onPressed: () async { - _tempPosition.value = Duration( - seconds: - skipDuration - _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds - skipDuration, - ), - ); - _tempPosition.value = null; - }, + onPressed: () async => await _seekBy(-skipDuration), icon: Stack( children: [ const Positioned.fill( @@ -1672,19 +1644,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo height: 50, width: 50, child: IconButton( - onPressed: () async { - _tempPosition.value = Duration( - seconds: - skipDuration + _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds + skipDuration, - ), - ); - _tempPosition.value = null; - }, + onPressed: () async => await _seekBy(skipDuration), icon: Stack( children: [ const Positioned.fill( From 3aa5c73dbade3cd9a486cfae61b015979b53ce2f Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:38:26 +0200 Subject: [PATCH 15/30] Improve Performance `_resize(fit)` was called on every Build. `_resize` posts a WidgetsBinding frame callback unconditionally. If fit hasn't changed, this is wasted work. --- lib/modules/anime/anime_player_view.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index b613109c..516d0d55 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -1948,7 +1948,10 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ); } + BoxFit? _lastFit; void _resize(BoxFit fit) async { + if (fit == _lastFit) return; + _lastFit = fit; // Wait for the widget tree to settle before updating fit await WidgetsBinding.instance.endOfFrame; if (mounted) { From c09eb5351d1ab56ca0e77af9a1c308a70ee56b3b Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:45:02 +0200 Subject: [PATCH 16/30] Reduce Code Duplication in reader_view.dart extract helper method `_goToChapter()`, to reduce the code duplication in the `ReaderKeyboardHandler` and `ReaderBottomBar` call. --- lib/modules/manga/reader/reader_view.dart | 65 ++++++++++------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index de01f52c..132e5000 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -346,6 +346,29 @@ class _MangaChapterPageGalleryState ref.read(fullScreenReaderStateProvider.notifier).set(!value!); } + /// Goes to either next or previous chapter + /// + /// The [next] parameter determines the navigation direction: + /// - `true` -> navigate to next chapter + /// - `false` -> navigate to previous chapter + /// + /// If the reader is already at the first or last chapter (depending on + /// the direction), the method returns without navigating. + void _goToChapter(bool next) { + final idx = _readerController.getChapterIndex(); + if (next && idx.$1 == 0) return; + if (!next && idx.$1 + 1 == _readerController.getChaptersLength(idx.$2)) { + return; + } + _isNavigatingToChapter = true; + pushReplacementMangaReaderView( + context: context, + chapter: next + ? _readerController.getNextChapter() + : _readerController.getPrevChapter(), + ); + } + @override Widget build(BuildContext context) { final animatePageTransitions = ref.watch( @@ -373,30 +396,8 @@ class _MangaChapterPageGalleryState ), onEscape: () => _goBack(context), onFullScreen: () => _setFullScreen(), - onNextChapter: () { - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; - if (hasNextChapter) { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ); - } - }, - onPreviousChapter: () { - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - if (hasPrevChapter) { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ); - } - }, + onNextChapter: () => _goToChapter(true), + onPreviousChapter: () => _goToChapter(false), ).wrapWithKeyboardListener( isReverseHorizontal: _isReverseHorizontal, child: NotificationListener( @@ -815,20 +816,8 @@ class _MangaChapterPageGalleryState ), hasNextChapter: _readerController.getChapterIndex().$1 != 0, - onPreviousChapter: () { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ); - }, - onNextChapter: () { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ); - }, + onPreviousChapter: () => _goToChapter(false), + onNextChapter: () => _goToChapter(true), onSliderChanged: (value, ref) { _currentPageDisplayIndex.value = value; ref From ffa8f15c8875501626c34ec21a6358d1116c0c31 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:56:12 +0200 Subject: [PATCH 17/30] ReaderModeExtension to Reduce Code Duplication --- lib/models/settings.dart | 12 +++++++++ .../providers/reader_controller_provider.dart | 4 +-- lib/modules/manga/reader/reader_view.dart | 27 ++++++++----------- .../services/page_navigation_service.dart | 11 ++------ .../widgets/chapter_transition_page.dart | 21 +++++---------- .../reader/widgets/reader_bottom_bar.dart | 4 +-- .../reader/widgets/reader_settings_modal.dart | 12 +++------ 7 files changed, 37 insertions(+), 54 deletions(-) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7dfb6d4f..274b22e0 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1256,6 +1256,18 @@ enum ReaderMode { horizontalContinuousRTL, } +extension ReaderModeExtension on ReaderMode { + bool get isContinuous => isVerticalContinuous || isHorizontalContinuous; + bool get isVertical => this == ReaderMode.vertical || isVerticalContinuous; + bool get isVerticalContinuous => + this == ReaderMode.verticalContinuous || this == ReaderMode.webtoon; + bool get isHorizontalContinuous => + this == ReaderMode.horizontalContinuous || + this == ReaderMode.horizontalContinuousRTL; + bool get isRTL => + this == ReaderMode.rtl || this == ReaderMode.horizontalContinuousRTL; +} + enum NovelTextAlign { left, center, right, block } enum PageMode { onePage, doublePage } diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 1ca48912..d93bcd24 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -185,9 +185,7 @@ class ReaderController extends _$ReaderController if (chapter.isRead! || incognitoMode) return; if (!save && newIndex == _lastSavedIndex) return; _lastSavedIndex = newIndex; - final isContinuousLike = - getReaderMode() == ReaderMode.verticalContinuous || - getReaderMode() == ReaderMode.webtoon; + final isContinuousLike = getReaderMode().isVerticalContinuous; final isRead = isContinuousLike ? (newIndex + 2) >= getPageLength([]) - 1 : (newIndex + 2) >= getPageLength([]); diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 132e5000..0d14a73a 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -377,19 +377,17 @@ class _MangaChapterPageGalleryState final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); final readerMode = ref.watch(_currentReaderMode); - final bool isHorizontalContinuous = - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + final bool isHorizontalContinuous = readerMode!.isHorizontalContinuous; final l10n = l10nLocalizations(context)!; return ReaderKeyboardHandler( onPreviousPage: () => navigationService.previousPage( - readerMode: readerMode!, + readerMode: readerMode, currentIndex: _currentIndex!, animate: animatePageTransitions, ), onNextPage: () => navigationService.nextPage( - readerMode: readerMode!, + readerMode: readerMode, currentIndex: _currentIndex!, maxPages: _pageViewPageCount, animate: animatePageTransitions, @@ -419,7 +417,7 @@ class _MangaChapterPageGalleryState builder: (context, failedToLoadImage, child) { return Stack( children: [ - _isContinuousMode() + readerMode.isContinuous ? ImageViewWebtoon( pages: pages, itemScrollController: _itemScrollController, @@ -760,15 +758,15 @@ class _MangaChapterPageGalleryState navigationLayout: navigationLayout, isRTL: _isReverseHorizontal, hasImageError: failedToLoadImage, - isContinuousMode: _isContinuousMode(), + isContinuousMode: readerMode.isContinuous, onToggleUI: _isViewFunction, onPreviousPage: () => navigationService.previousPage( - readerMode: readerMode!, + readerMode: readerMode, currentIndex: _currentIndex!, animate: animatePageTransitions, ), onNextPage: () => navigationService.nextPage( - readerMode: readerMode!, + readerMode: readerMode, currentIndex: _currentIndex!, maxPages: _pageViewPageCount, animate: animatePageTransitions, @@ -921,7 +919,7 @@ class _MangaChapterPageGalleryState formatCurrentIndex: _currentIndexLabel, ), ReaderAutoScrollButton( - isContinuousMode: _isContinuousMode(), + isContinuousMode: readerMode.isContinuous, isUiVisible: _isView, autoScrollPage: _autoScrollPage, autoScroll: _autoScroll, @@ -1529,11 +1527,8 @@ class _MangaChapterPageGalleryState int get _pageViewPageCount => _isDoublePageActive ? (pages.length / 2).ceil() : pages.length; - bool _isContinuousMode() { - final readerMode = ref.read(_currentReaderMode); - return readerMode == ReaderMode.verticalContinuous || - readerMode == ReaderMode.webtoon || - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + bool _isContinuousMode([ReaderMode? mode]) { + final readerMode = mode ?? ref.read(_currentReaderMode); + return readerMode!.isContinuous; } } diff --git a/lib/modules/manga/reader/services/page_navigation_service.dart b/lib/modules/manga/reader/services/page_navigation_service.dart index 7f52e4f4..e754b4fe 100644 --- a/lib/modules/manga/reader/services/page_navigation_service.dart +++ b/lib/modules/manga/reader/services/page_navigation_service.dart @@ -30,7 +30,7 @@ class PageNavigationService { }) { if (index < 0) return; - if (_isContinuousMode(readerMode)) { + if (readerMode.isContinuous) { _navigateContinuous(index, animate); } else { _navigatePaged(index, animate); @@ -70,7 +70,7 @@ class PageNavigationService { void jumpToPage({required int index, required ReaderMode readerMode}) { if (index < 0) return; - if (_isContinuousMode(readerMode)) { + if (readerMode.isContinuous) { itemScrollController.jumpTo(index: index); } else { if (extendedController.hasClients) { @@ -104,13 +104,6 @@ class PageNavigationService { extendedController.jumpToPage(index); } } - - bool _isContinuousMode(ReaderMode mode) { - return mode == ReaderMode.verticalContinuous || - mode == ReaderMode.webtoon || - mode == ReaderMode.horizontalContinuous || - mode == ReaderMode.horizontalContinuousRTL; - } } /// Mixin to add page navigation capabilities to reader state. diff --git a/lib/modules/manga/reader/widgets/chapter_transition_page.dart b/lib/modules/manga/reader/widgets/chapter_transition_page.dart index 6a48b071..1397f693 100644 --- a/lib/modules/manga/reader/widgets/chapter_transition_page.dart +++ b/lib/modules/manga/reader/widgets/chapter_transition_page.dart @@ -17,20 +17,11 @@ class ChapterTransitionPage extends StatelessWidget { 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) { return Container( color: Theme.of(context).scaffoldBackgroundColor, - child: _isVertical + child: readerMode.isVertical ? _buildVerticalScaffold(context) : _buildHorizontalScaffold(context), ); @@ -174,7 +165,9 @@ class ChapterTransitionPage extends StatelessWidget { final Widget arrowIcon = Icon( nextChapter != null - ? (_isRTL ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right) + ? (readerMode.isRTL + ? Icons.keyboard_arrow_left + : Icons.keyboard_arrow_right) : Icons.check_circle_outline, size: 36, color: nextChapter != null @@ -214,7 +207,7 @@ class ChapterTransitionPage extends StatelessWidget { child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, - children: _isRTL + children: readerMode.isRTL ? [ Expanded(child: nextCard), const SizedBox(width: 12), @@ -316,9 +309,9 @@ class ChapterTransitionPage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ RotatedBox( - quarterTurns: _isVertical + quarterTurns: readerMode.isVertical ? 1 // turn 90° clockwise, so Icon is pointing down - : _isRTL + : readerMode.isRTL ? 2 // turn 180°, so Icon is pointing left : 0, // no rotation, Icon points to the right. child: Icon( diff --git a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart index 8c93596b..3ef07d11 100644 --- a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart @@ -107,9 +107,7 @@ class ReaderBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final readerMode = ref.watch(currentReaderModeProvider); - final isHorizontalContinuous = - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + final isHorizontalContinuous = readerMode!.isHorizontalContinuous; return Positioned( bottom: 0, diff --git a/lib/modules/manga/reader/widgets/reader_settings_modal.dart b/lib/modules/manga/reader/widgets/reader_settings_modal.dart index b5c89a18..a446b444 100644 --- a/lib/modules/manga/reader/widgets/reader_settings_modal.dart +++ b/lib/modules/manga/reader/widgets/reader_settings_modal.dart @@ -131,12 +131,6 @@ class _ReadingModeTab extends ConsumerWidget { final showPageGaps = ref.watch(showPageGapsStateProvider); final webtoonSidePadding = ref.watch(webtoonSidePaddingStateProvider); - final isContinuousMode = - readerMode == ReaderMode.verticalContinuous || - readerMode == ReaderMode.webtoon || - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; - return SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(vertical: 20), @@ -206,7 +200,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Show Page Gaps (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) SwitchListTile( value: showPageGaps, title: Text( @@ -224,7 +218,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Webtoon Side Padding (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) ListTile( title: Text( '${l10n.webtoon_side_padding}: $webtoonSidePadding%', @@ -249,7 +243,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Auto-scroll (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) ValueListenableBuilder( valueListenable: autoScrollPage, builder: (context, valueT, child) { From 5bab1492a45d8cf0b50059eed1ca0b354ac2debe Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:16:48 +0200 Subject: [PATCH 18/30] Reduce Code Duplication by Adding Platform Helper New file `lib/utils/platform_utils.dart`, to stop defining isDesktop and isMobile everywhere. --- lib/modules/anime/anime_player_view.dart | 30 ++++++++----------- lib/modules/anime/widgets/custom_seekbar.dart | 3 +- lib/modules/anime/widgets/mobile.dart | 2 +- .../anime/widgets/play_or_pause_button.dart | 10 ++----- .../library/widgets/library_app_bar.dart | 3 +- lib/modules/manga/reader/reader_view.dart | 3 +- .../reader/widgets/color_filter_widget.dart | 3 +- .../manga/reader/widgets/reader_app_bar.dart | 3 +- .../more/categories/categories_screen.dart | 9 ++---- lib/modules/novel/novel_reader_view.dart | 2 +- lib/utils/platform_utils.dart | 6 ++++ 11 files changed, 32 insertions(+), 42 deletions(-) create mode 100644 lib/utils/platform_utils.dart diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 516d0d55..b574af29 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -43,6 +43,7 @@ import 'package:mangayomi/services/get_video_list.dart'; import 'package:mangayomi/services/torrent_server.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/language.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/system_ui.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit/generated/libmpv/bindings.dart' as generated; @@ -57,8 +58,6 @@ import 'package:window_manager/window_manager.dart' show windowManager; import 'widgets/search_subtitles.dart'; -bool _isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; - class AnimePlayerView extends riv.ConsumerStatefulWidget { final int episodeId; const AnimePlayerView({super.key, required this.episodeId}); @@ -73,7 +72,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { bool desktopFullScreenPlayer = false; @override void dispose() { - if (_isDesktop) { + if (isDesktop) { setFullScreen(value: desktopFullScreenPlayer); } for (var infoHash in _infoHashList) { @@ -319,7 +318,7 @@ class _AnimeStreamPageState extends riv.ConsumerState } // If the last episode of an Anime has ended, exit fullscreen mode final isFullScreen = ref.read(fullscreenProvider); - if (!hasNextEpisode && val && _isDesktop && isFullScreen) { + if (!hasNextEpisode && val && isDesktop && isFullScreen) { setFullScreen(value: false); ref.read(fullscreenProvider.notifier).state = false; widget.desktopFullScreenPlayer.call(false); @@ -840,7 +839,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo "$defaultSkipIntroLength", ); } catch (_) {} - if (_isDesktop && _firstTime) { + if (isDesktop && _firstTime) { final globalFullscreen = ref.read(fullScreenPlayerStateProvider); // Delay fullscreen until after the first frame so the window is ready. // On Windows, calling setFullScreen before the widget tree is built @@ -852,7 +851,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo }); _firstTime = false; } - if (!_isDesktop) { + if (!isDesktop) { final forceLandscape = ref.read(forceLandscapePlayerStateProvider); if (forceLandscape) { _setLandscapeMode(true); @@ -971,7 +970,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _skipPhase.dispose(); _subDelayController.dispose(); _subSpeedController.dispose(); - if (!_isDesktop) _setLandscapeMode(false); + if (!isDesktop) _setLandscapeMode(false); discordRpc?.showIdleText(); discordRpc?.showOriginalTimestamp(); _streamController.keepAliveLink?.close(); @@ -1594,10 +1593,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo }, icon: const Icon(Icons.skip_previous, color: Colors.white), ), - CustomPlayOrPauseButton( - controller: _controller, - isDesktop: _isDesktop, - ), + CustomPlayOrPauseButton(controller: _controller), if (hasNextEpisode) IconButton( onPressed: () async { @@ -1802,7 +1798,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo return Row( children: [ IconButton( - padding: _isDesktop ? EdgeInsets.zero : const EdgeInsets.all(5), + padding: isDesktop ? EdgeInsets.zero : const EdgeInsets.all(5), onPressed: () => _videoSettingDraggableMenu(context), icon: const Icon(Icons.video_settings, color: Colors.white), ), @@ -1829,7 +1825,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _changeFitLabel(ref); }, ), - if (_isDesktop) + if (isDesktop) CustomMaterialDesktopFullscreenButton( controller: _controller, desktopFullScreenPlayer: widget.desktopFullScreenPlayer, @@ -1853,16 +1849,14 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo final fullScreen = ref.watch(fullscreenProvider); return Padding( padding: EdgeInsets.only( - top: !_isDesktop && !fullScreen - ? MediaQuery.of(context).padding.top - : 0, + top: !isDesktop && !fullScreen ? MediaQuery.of(context).padding.top : 0, ), child: Row( children: [ BackButton( color: Colors.white, onPressed: () { - if (_isDesktop && fullScreen) { + if (isDesktop && fullScreen) { setFullScreen(value: !fullScreen); ref.read(fullscreenProvider.notifier).state = !fullScreen; widget.desktopFullScreenPlayer.call(!fullScreen); @@ -1979,7 +1973,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ), fit: fit, key: _key, - controls: (state) => _isDesktop + controls: (state) => isDesktop ? DesktopControllerWidget( videoController: _controller, topButtonBarWidget: _topButtonBar(context), diff --git a/lib/modules/anime/widgets/custom_seekbar.dart b/lib/modules/anime/widgets/custom_seekbar.dart index e4305c83..bcb40c2c 100644 --- a/lib/modules/anime/widgets/custom_seekbar.dart +++ b/lib/modules/anime/widgets/custom_seekbar.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:mangayomi/modules/anime/widgets/custom_track_shape.dart'; @@ -61,7 +61,6 @@ class CustomSeekBarState extends State { buffer = player.state.buffer; } - final isDesktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; @override Widget build(BuildContext context) { final maxValue = max(duration.inMilliseconds.toDouble(), 0).toDouble(); diff --git a/lib/modules/anime/widgets/mobile.dart b/lib/modules/anime/widgets/mobile.dart index c797973e..5db55b57 100644 --- a/lib/modules/anime/widgets/mobile.dart +++ b/lib/modules/anime/widgets/mobile.dart @@ -918,7 +918,7 @@ List mobilePrimaryButtonBar( ), ), const Spacer(), - CustomPlayOrPauseButton(controller: controller, isDesktop: false), + CustomPlayOrPauseButton(controller: controller), const Spacer(), IconButton( onPressed: hasNextEpisode diff --git a/lib/modules/anime/widgets/play_or_pause_button.dart b/lib/modules/anime/widgets/play_or_pause_button.dart index e6443edc..73ac357b 100644 --- a/lib/modules/anime/widgets/play_or_pause_button.dart +++ b/lib/modules/anime/widgets/play_or_pause_button.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:media_kit_video/media_kit_video.dart'; // BUTTON: PLAY/PAUSE @@ -7,13 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart'; /// A material design play/pause button. class CustomPlayOrPauseButton extends StatefulWidget { final VideoController controller; - final bool isDesktop; - const CustomPlayOrPauseButton({ - super.key, - required this.controller, - required this.isDesktop, - }); + const CustomPlayOrPauseButton({super.key, required this.controller}); @override CustomPlayOrPauseButtonState createState() => CustomPlayOrPauseButtonState(); @@ -29,7 +25,7 @@ class CustomPlayOrPauseButtonState extends State StreamSubscription? subscription; - double get iconSize => widget.isDesktop ? 25 : 65; + double get iconSize => isDesktop ? 25 : 65; @override void setState(VoidCallback fn) { diff --git a/lib/modules/library/widgets/library_app_bar.dart b/lib/modules/library/widgets/library_app_bar.dart index bc38b898..152610ed 100644 --- a/lib/modules/library/widgets/library_app_bar.dart +++ b/lib/modules/library/widgets/library_app_bar.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/manga.dart'; @@ -76,7 +76,6 @@ class LibraryAppBar extends ConsumerWidget implements PreferredSizeWidget { ), ); final l10n = l10nLocalizations(context)!; - final isMobile = Platform.isIOS || Platform.isAndroid; if (isLongPressed) { return manga.when( diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 0d14a73a..a8b3fce5 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -150,7 +150,6 @@ class _MangaChapterPageGalleryState readerControllerProvider(chapter: chapter).notifier, ); - bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; final ValueNotifier _isScrolling = ValueNotifier(false); Timer? _scrollIdleTimer; final Stopwatch _readingStopwatch = Stopwatch(); diff --git a/lib/modules/manga/reader/widgets/color_filter_widget.dart b/lib/modules/manga/reader/widgets/color_filter_widget.dart index 81ef7dfb..23e80727 100644 --- a/lib/modules/manga/reader/widgets/color_filter_widget.dart +++ b/lib/modules/manga/reader/widgets/color_filter_widget.dart @@ -6,6 +6,7 @@ import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.d import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; // ── Color matrix utilities (5×4 row-major, 20 elements) ── @@ -197,7 +198,7 @@ Widget customColorFilterListTile( Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: context.isDesktop ? null : 3, + trackHeight: isDesktop ? null : 3, overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0), ), child: Slider( diff --git a/lib/modules/manga/reader/widgets/reader_app_bar.dart b/lib/modules/manga/reader/widgets/reader_app_bar.dart index 063a33ec..e2c862f5 100644 --- a/lib/modules/manga/reader/widgets/reader_app_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_app_bar.dart @@ -8,6 +8,7 @@ import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_pr import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/utils.dart'; /// The app bar for the manga reader. @@ -65,8 +66,6 @@ class ReaderAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - final isDesktop = - Platform.isMacOS || Platform.isLinux || Platform.isWindows; final isLocalArchive = chapter.manga.value?.isLocalArchive ?? false; double height = isVisible diff --git a/lib/modules/more/categories/categories_screen.dart b/lib/modules/more/categories/categories_screen.dart index a89a0980..25c6af09 100644 --- a/lib/modules/more/categories/categories_screen.dart +++ b/lib/modules/more/categories/categories_screen.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar_community/isar.dart'; @@ -114,16 +114,13 @@ class _CategoriesTabState extends ConsumerState super.dispose(); } - final bool _isDesktop = - Platform.isMacOS || Platform.isLinux || Platform.isWindows; - /// Moves a category from `index` to `newIndex` in the list, /// swaps their positions in memory, and persists the change in Isar. Future _moveCategory(int index, int newIndex) async { // Prevent invalid moves (out of bounds) if (newIndex < 0 || newIndex >= _entries.length) return; - if (_isDesktop && mounted) { + if (isDesktop && mounted) { setState(() { _animatingFromIndex = index; _animatingToIndex = newIndex; @@ -185,7 +182,7 @@ class _CategoriesTabState extends ConsumerState Widget itemWidget = _buildCategoryCard(context, category, index); - if (_isDesktop && + if (isDesktop && _animatingFromIndex != null && _animatingToIndex != null) { if (index == _animatingFromIndex || diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index a2342e7e..ba831409 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -24,6 +24,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/get_html_content.dart'; import 'package:mangayomi/src/rust/api/epub.dart'; import 'package:mangayomi/utils/extensions/dom_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/system_ui.dart'; import 'package:mangayomi/utils/utils.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; @@ -73,7 +74,6 @@ class _NovelWebViewState extends ConsumerState double offset = 0; double maxOffset = 0; int fontSize = 14; - bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; bool get _ttsSupported => !Platform.isLinux; final Stopwatch _readingStopwatch = Stopwatch(); diff --git a/lib/utils/platform_utils.dart b/lib/utils/platform_utils.dart new file mode 100644 index 00000000..d61f002e --- /dev/null +++ b/lib/utils/platform_utils.dart @@ -0,0 +1,6 @@ +import 'dart:io'; + +final bool isDesktop = + Platform.isMacOS || Platform.isLinux || Platform.isWindows; + +final bool isMobile = Platform.isAndroid || Platform.isIOS; From 0c46b88002d11e6a43b3264edb573192cc80acfd Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:17:51 +0200 Subject: [PATCH 19/30] Remove isMobile and isDesktop Extension Platform checks shouldn't be dependent on BuildContext. --- lib/utils/extensions/build_context_extensions.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/utils/extensions/build_context_extensions.dart b/lib/utils/extensions/build_context_extensions.dart index 823d3616..5512d99b 100644 --- a/lib/utils/extensions/build_context_extensions.dart +++ b/lib/utils/extensions/build_context_extensions.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; extension BuildContextExtensions on BuildContext { @@ -23,14 +21,6 @@ extension BuildContextExtensions on BuildContext { return isLight ? Colors.white : Colors.black; } - bool get isDesktop { - return Platform.isMacOS || Platform.isLinux || Platform.isWindows; - } - - bool get isMobile { - return Platform.isIOS || Platform.isAndroid; - } - Color get textColor { return themeData.textTheme.bodyLarge!.color!; } From dd6ff2580aace2271ef9765f915379f00525f51e Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:42:13 +0200 Subject: [PATCH 20/30] Use Platform Helper Everywhere Possible --- lib/main.dart | 18 +++++++----------- lib/modules/anime/anime_player_view.dart | 3 +-- .../anime/widgets/search_subtitles.dart | 3 ++- lib/modules/main_view/main_screen.dart | 6 +++--- .../detail/widgets/tracker_search_widget.dart | 5 ++--- .../more/settings/browse/browse_screen.dart | 7 +++---- .../browse/extension_server_screen.dart | 10 ++++------ .../more/settings/reader/reader_screen.dart | 5 ++--- lib/router/router.dart | 6 +++--- lib/services/m_extension_server.dart | 3 ++- lib/services/torrent_server.dart | 3 ++- lib/utils/platform_utils.dart | 5 +++++ 12 files changed, 36 insertions(+), 38 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index d2387a2f..eed7cade 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,7 @@ import 'package:mangayomi/services/download_manager/m_downloader.dart'; import 'package:mangayomi/src/rust/frb_generated.dart'; import 'package:mangayomi/utils/discord_rpc.dart'; import 'package:mangayomi/utils/log/logger.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/url_protocol/api.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/theme_provider.dart'; import 'package:mangayomi/modules/library/providers/file_scanner.dart'; @@ -84,7 +85,7 @@ void main(List args) async { await RustLib.init(); await imgCropIsolate.start(); await getIsolateService.start(); - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { await windowManager.ensureInitialized(); await WindowGeometry.restore(); } @@ -120,13 +121,10 @@ void main(List args) async { Future _postLaunchInit(StorageProvider storage) async { await AppLogger.init(); unawaited(MDownloader.initializeIsolatePool(poolSize: 6)); - final hivePath = (Platform.isIOS || Platform.isMacOS) - ? "databases" - : p.join("Mangayomi", "databases"); + final hivePath = isApple ? "databases" : p.join("Mangayomi", "databases"); await Hive.initFlutter(Platform.isAndroid ? "" : hivePath); Hive.registerAdapter(TrackSearchAdapter()); - if ((Platform.isMacOS || Platform.isLinux || Platform.isWindows) && - !kDebugMode) { + if (isDesktop && !kDebugMode) { discordRpc = DiscordRPC(applicationId: "1395040506677039157"); await discordRpc?.initialize(); } @@ -151,9 +149,7 @@ class _MyAppState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - if (!(Platform.isAndroid || Platform.isIOS)) { - windowManager.addListener(this); - } + if (!isMobile) windowManager.addListener(this); initializeDateFormatting(); customDns = ref.read(customDnsStateProvider); _checkTrackerRefresh(); @@ -210,7 +206,7 @@ class _MyAppState extends ConsumerState builder: (context, child) { child = BotToastInit()(context, child); final appChild = child; - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { child = _MouseBackButtonHandler(router: router, child: appChild); } else { child = appChild; @@ -240,7 +236,7 @@ class _MyAppState extends ConsumerState @override void dispose() { WidgetsBinding.instance.removeObserver(this); - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { windowManager.removeListener(this); WindowGeometry.save(); } diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index b574af29..4e6c71cb 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -2367,6 +2367,5 @@ mixin _AlwaysOnTopStateMixin on State { } // Whether the platform support AlwaysOnTop feature. - bool _supportAlwaysOnTop() => - !kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows); + bool _supportAlwaysOnTop() => !kIsWeb && isDesktop; } diff --git a/lib/modules/anime/widgets/search_subtitles.dart b/lib/modules/anime/widgets/search_subtitles.dart index 112a6db7..dd1c2342 100644 --- a/lib/modules/anime/widgets/search_subtitles.dart +++ b/lib/modules/anime/widgets/search_subtitles.dart @@ -14,6 +14,7 @@ import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/services/http/rhttp/src/model/settings.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/log/logger.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:path/path.dart' as path; import 'package:super_sliver_list/super_sliver_list.dart'; @@ -118,7 +119,7 @@ class _SubtitlesWidgetSearchState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 10), child: TextFormField( onTap: () { - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { setState(() { hide = true; }); diff --git a/lib/modules/main_view/main_screen.dart b/lib/modules/main_view/main_screen.dart index d51a1bf5..e84a64da 100644 --- a/lib/modules/main_view/main_screen.dart +++ b/lib/modules/main_view/main_screen.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -544,7 +544,7 @@ class _DownloadedOnlyBar extends StatelessWidget { return Material( child: AnimatedContainer( height: downloadedOnly - ? Platform.isAndroid || Platform.isIOS + ? isMobile ? MediaQuery.of(context).padding.top * 2 : 50 : 0, @@ -583,7 +583,7 @@ class _IncognitoModeBar extends StatelessWidget { return Material( child: AnimatedContainer( height: incognitoMode - ? Platform.isAndroid || Platform.isIOS + ? isMobile ? MediaQuery.of(context).padding.top * 2 : 50 : 0, diff --git a/lib/modules/manga/detail/widgets/tracker_search_widget.dart b/lib/modules/manga/detail/widgets/tracker_search_widget.dart index 6d3013ca..d2ec5215 100644 --- a/lib/modules/manga/detail/widgets/tracker_search_widget.dart +++ b/lib/modules/manga/detail/widgets/tracker_search_widget.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/manga.dart'; @@ -210,7 +209,7 @@ class _TrackerWidgetSearchState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 10), child: TextFormField( onTap: () { - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { setState(() { hide = true; }); diff --git a/lib/modules/more/settings/browse/browse_screen.dart b/lib/modules/more/settings/browse/browse_screen.dart index 9d76e6d5..57728b71 100644 --- a/lib/modules/more/settings/browse/browse_screen.dart +++ b/lib/modules/more/settings/browse/browse_screen.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -59,12 +58,12 @@ class BrowseSScreen extends ConsumerWidget { ListTile( onTap: () => context.push('/extensionServer'), title: Text( - Platform.isAndroid || Platform.isIOS + isMobile ? l10n.android_proxy_server : l10n.android_proxy_server_mihon, ), subtitle: Text( - Platform.isAndroid || Platform.isIOS + isMobile ? l10n.apkbridge_description : l10n.android_proxy_server_mihon_description, style: TextStyle( diff --git a/lib/modules/more/settings/browse/extension_server_screen.dart b/lib/modules/more/settings/browse/extension_server_screen.dart index ab871d92..da721797 100644 --- a/lib/modules/more/settings/browse/extension_server_screen.dart +++ b/lib/modules/more/settings/browse/extension_server_screen.dart @@ -18,6 +18,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/fetch_sources_list.dart'; import 'package:mangayomi/services/m_extension_server.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:path/path.dart' as path; import 'package:url_launcher/url_launcher.dart'; @@ -56,14 +57,11 @@ class _ExtensionServerScreenState extends ConsumerState { bool get _requiresJre => !Platform.isIOS; - bool get _showExtensionServerSection => - !Platform.isAndroid && !Platform.isIOS; + bool get _showExtensionServerSection => !isMobile; - bool get _showAndroidProxyServerSection => - Platform.isAndroid || Platform.isIOS; + bool get _showAndroidProxyServerSection => isMobile; - bool get _showDesktopAdvancedApkBridgeSection => - Platform.isWindows || Platform.isLinux || Platform.isMacOS; + bool get _showDesktopAdvancedApkBridgeSection => isDesktop; bool get _isInstalled => _serverExists && (!_requiresJre || _jreExists); diff --git a/lib/modules/more/settings/reader/reader_screen.dart b/lib/modules/more/settings/reader/reader_screen.dart index 4520a1a9..eca47d8e 100644 --- a/lib/modules/more/settings/reader/reader_screen.dart +++ b/lib/modules/more/settings/reader/reader_screen.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/settings.dart'; @@ -366,7 +365,7 @@ class ReaderScreen extends ConsumerWidget { style: TextStyle(fontSize: 11, color: context.secondaryColor), ), ), - if (!(Platform.isAndroid || Platform.isIOS)) + if (!isMobile) SwitchListTile( value: fullScreenReader, title: Text(context.l10n.fullscreen), diff --git a/lib/router/router.dart b/lib/router/router.dart index 9a7d004b..306b70b3 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -295,7 +295,7 @@ class RouterNotifier extends ChangeNotifier { return child!; } }, - pageBuilder: (Platform.isIOS || Platform.isMacOS) + pageBuilder: isApple ? (context, state) { final pageChild = builder != null ? builder(state.extra as T) @@ -312,7 +312,7 @@ Page transitionPage({required LocalKey key, required child}) { } Route createRoute({required Widget page}) { - return (Platform.isIOS || Platform.isMacOS) + return isApple ? CupertinoPageRoute(builder: (context) => page) : MaterialPageRoute(builder: (context) => page); } diff --git a/lib/services/m_extension_server.dart b/lib/services/m_extension_server.dart index 8f6f55ae..b905560e 100644 --- a/lib/services/m_extension_server.dart +++ b/lib/services/m_extension_server.dart @@ -6,6 +6,7 @@ import 'package:m_extension_server/m_extension_server.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; class MExtensionServerPlatform { WidgetRef ref; @@ -31,7 +32,7 @@ class MExtensionServerPlatform { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final port = server.port; await server.close(); - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + if (isDesktop) { final settings = isar.settings.getSync(227); final jrePath = settings?.jrePath; final serverJarPath = settings?.extensionServerPath; diff --git a/lib/services/torrent_server.dart b/lib/services/torrent_server.dart index ffacd92c..5b651ee3 100644 --- a/lib/services/torrent_server.dart +++ b/lib/services/torrent_server.dart @@ -10,6 +10,7 @@ import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/ffi/torrent_server_ffi.dart' as libmtorrentserver_ffi; +import 'package:mangayomi/utils/platform_utils.dart'; String _buildQueryString(Map> parameters) { final segments = []; @@ -133,7 +134,7 @@ class MTorrentServer { final path = (await StorageProvider().getBtDirectory())!.path; final config = jsonEncode({"path": path, "address": "127.0.0.1:0"}); int port = 0; - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { const channel = MethodChannel( 'com.kodjodevf.mangayomi.libmtorrentserver', ); diff --git a/lib/utils/platform_utils.dart b/lib/utils/platform_utils.dart index d61f002e..d5cb43b0 100644 --- a/lib/utils/platform_utils.dart +++ b/lib/utils/platform_utils.dart @@ -1,6 +1,11 @@ import 'dart:io'; +/// macOS, Linux or Windows final bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; +/// Android or iOS final bool isMobile = Platform.isAndroid || Platform.isIOS; + +/// macOS or iOS +final bool isApple = Platform.isMacOS || Platform.isIOS; From b1459cffc1bffc0576a82f2cac7ebd9cdfb7da55 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:50:22 +0200 Subject: [PATCH 21/30] Reduce Code Duplication in novel_reader_view.dart Why duplicate the entire `ReaderAppBar` inside novel_reader_view.dart?? --- lib/modules/novel/novel_reader_view.dart | 154 +++++++---------------- 1 file changed, 43 insertions(+), 111 deletions(-) diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index ba831409..1d698415 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -12,7 +12,7 @@ import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/anime/widgets/desktop.dart'; -import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart'; +import 'package:mangayomi/modules/manga/reader/widgets/reader_app_bar.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/novel/novel_reader_controller_provider.dart'; import 'package:mangayomi/modules/novel/tts/novel_tts_service.dart'; @@ -936,117 +936,49 @@ class _NovelWebViewState extends ConsumerState } Widget _appBar() { - if (!_isView && Platform.isIOS) { - return const SizedBox.shrink(); - } - final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - double height = _isView - ? Platform.isIOS - ? 120 - : !fullScreenReader && !isDesktop - ? 55 - : 80 - : 0; - return Positioned( - top: 0, - child: AnimatedContainer( - width: context.width(1), - height: height, - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: PreferredSize( - preferredSize: Size.fromHeight(height), - child: AppBar( - centerTitle: false, - automaticallyImplyLeading: false, - titleSpacing: 0, - leading: BackButton( - onPressed: () { - Navigator.pop(context); - }, - ), - title: ListTile( - dense: true, - title: SizedBox( - width: context.width(0.8), - child: Text( - '${_readerController.getMangaName()} ', - style: const TextStyle(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - ), - subtitle: SizedBox( - width: context.width(0.8), - child: Text( - _readerController.getChapterTitle(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - actions: [ - btnToShowChapterListDialog( - context, - context.l10n.chapters, - widget.chapter, - ), - IconButton( - onPressed: () { - _readerController.setChapterBookmarked(); - setState(() { - _isBookmarked = !_isBookmarked; - }); - }, - icon: Icon( - _isBookmarked - ? Icons.bookmark - : Icons.bookmark_border_outlined, - ), - ), - if ((chapter.manga.value!.isLocalArchive ?? false) == false) - IconButton( - onPressed: () async { - final manga = chapter.manga.value!; - final source = getSource( - manga.lang!, - manga.source!, - manga.sourceId, - )!; - String url = chapter.url!.startsWith('/') - ? "${source.baseUrl}/${chapter.url!}" - : chapter.url!; - Map data = { - 'url': url, - 'sourceId': source.id.toString(), - 'title': chapter.name!, - }; - if (Platform.isLinux) { - final urll = Uri.parse(url); - if (!await launchUrl( - urll, - mode: LaunchMode.inAppBrowserView, - )) { - if (!await launchUrl( - urll, - mode: LaunchMode.externalApplication, - )) { - throw 'Could not launch $url'; - } - } - } else { - context.push("/mangawebview", extra: data); - } + return ReaderAppBar( + chapter: chapter, + mangaName: _readerController.getMangaName(), + chapterTitle: _readerController.getChapterTitle(), + isVisible: _isView, + isBookmarked: _isBookmarked, + backgroundColor: _backgroundColor, + onBackPressed: () => Navigator.pop(context), + onBookmarkPressed: () { + _readerController.setChapterBookmarked(); + setState(() => _isBookmarked = !_isBookmarked); + }, + onWebViewPressed: (chapter.manga.value!.isLocalArchive ?? false) + ? null + : () async { + final manga = chapter.manga.value!; + final source = getSource( + manga.lang!, + manga.source!, + manga.sourceId, + )!; + final url = chapter.url!.startsWith('/') + ? '${source.baseUrl}/${chapter.url!}' + : chapter.url!; + if (Platform.isLinux) { + final uri = Uri.parse(url); + await launchUrl( + uri, + mode: LaunchMode.inAppBrowserView, + ).catchError( + (_) => launchUrl(uri, mode: LaunchMode.externalApplication), + ); + } else { + context.push( + '/mangawebview', + extra: { + 'url': url, + 'sourceId': source.id.toString(), + 'title': chapter.name!, }, - icon: const Icon(Icons.public), - ), - ], - backgroundColor: _backgroundColor(context), - ), - ), - ), + ); + } + }, ); } From 8a2a57fbe572bf813c488ff39d387295cd3c157b Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:55:00 +0200 Subject: [PATCH 22/30] Reduce Code Duplication in novel_reader_view.dart Why duplicate the entire `ReaderAutoScrollButton` inside novel_reader_view.dart?? --- lib/modules/novel/novel_reader_view.dart | 38 +++++++----------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index 1d698415..d5d02c34 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -12,6 +12,7 @@ import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/anime/widgets/desktop.dart'; +import 'package:mangayomi/modules/manga/reader/widgets/auto_scroll_button.dart'; import 'package:mangayomi/modules/manga/reader/widgets/reader_app_bar.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/novel/novel_reader_controller_provider.dart'; @@ -767,7 +768,16 @@ class _NovelWebViewState extends ConsumerState _gestureTopBottom(ref.watch(novelTapToScrollStateProvider)), _appBar(), _bottomBar(backgroundColor), - _autoScrollPlayPauseBtn(), + ReaderAutoScrollButton( + isContinuousMode: true, + isUiVisible: _isView, + autoScrollPage: _autoScrollPage, + autoScroll: _autoScroll, + onToggle: () { + _autoPagescroll(); + _autoScroll.value = !_autoScroll.value; + }, + ), if (_ttsSupported && _showTts && _currentHtmlContent != null) @@ -800,32 +810,6 @@ class _NovelWebViewState extends ConsumerState ); } - Widget _autoScrollPlayPauseBtn() { - return Positioned( - bottom: 0, - right: 0, - child: !_isView - ? ValueListenableBuilder( - valueListenable: _autoScrollPage, - builder: (context, valueT, child) => valueT - ? ValueListenableBuilder( - valueListenable: _autoScroll, - builder: (context, value, child) => IconButton( - onPressed: () { - _autoPagescroll(); - _autoScroll.value = !value; - }, - icon: Icon( - value ? Icons.pause_circle : Icons.play_circle, - ), - ), - ) - : const SizedBox.shrink(), - ) - : const SizedBox.shrink(), - ); - } - Widget scaffoldWith( BuildContext context, Widget body, { From da2682aa482271dde7d3c33150b14768afb4ca44 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:33:35 +0200 Subject: [PATCH 23/30] Reduce Code Duplication by extracting `hasPreviousChapter` and `hasNextChapter` logic to the `ChapterControllerMixin`. --- lib/modules/anime/anime_player_view.dart | 9 ++------- .../anime_player_controller_provider.dart | 3 +++ .../reader/mixins/chapter_controller_mixin.dart | 7 +++++++ lib/modules/manga/reader/reader_view.dart | 16 ++++------------ 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 4e6c71cb..b6f08c1f 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -307,7 +307,7 @@ class _AnimeStreamPageState extends riv.ConsumerState discordRpc?.updateChapterTimestamp(_currentPosition.value, duration); }); - bool get hasNextEpisode => _streamController.getEpisodeIndex().$1 != 0; + bool get hasNextEpisode => _streamController.hasNextEpisode; late final StreamSubscription _completed = _player.stream.completed .listen((val) { @@ -1569,11 +1569,6 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo } Widget _desktopBottomButtonBar(BuildContext context) { - bool hasPrevEpisode = - _streamController.getEpisodeIndex().$1 + 1 != - _streamController.getEpisodesLength( - _streamController.getEpisodeIndex().$2, - ); final skipDuration = ref.watch(defaultDoubleTapToSkipLengthStateProvider); return Column( mainAxisAlignment: MainAxisAlignment.end, @@ -1583,7 +1578,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo children: [ Row( children: [ - if (hasPrevEpisode) + if (_streamController.hasPreviousEpisode) IconButton( onPressed: () { pushToNewEpisode( diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 8c9d0fd3..515ce1b0 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -44,6 +44,9 @@ class AnimeStreamController extends _$AnimeStreamController Chapter getPrevEpisode() => getPrevChapter(); Chapter getNextEpisode() => getNextChapter(); + bool get hasPreviousEpisode => hasPreviousChapter; + bool get hasNextEpisode => hasNextChapter; + int getEpisodesLength(bool isInFilterList) => getChaptersLength(isInFilterList); diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart index 9be8e5a4..3b27f726 100644 --- a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -33,6 +33,13 @@ mixin ChapterControllerMixin { // (which is more efficient since incognito status never changes mid-session). bool get incognitoMode => isar.settings.getSync(227)!.incognitoMode!; + bool get hasNextChapter => getChapterIndex().$1 != 0; + + bool get hasPreviousChapter { + final idx = getChapterIndex(); + return idx.$1 + 1 != getChaptersLength(idx.$2); + } + Settings getIsarSetting() => isar.settings.getSync(227)!; String getMangaName() => getManga().name!; diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index a8b3fce5..2440cda7 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -354,11 +354,8 @@ class _MangaChapterPageGalleryState /// If the reader is already at the first or last chapter (depending on /// the direction), the method returns without navigating. void _goToChapter(bool next) { - final idx = _readerController.getChapterIndex(); - if (next && idx.$1 == 0) return; - if (!next && idx.$1 + 1 == _readerController.getChaptersLength(idx.$2)) { - return; - } + if (next && !_readerController.hasNextChapter) return; + if (!next && !_readerController.hasPreviousChapter) return; _isNavigatingToChapter = true; pushReplacementMangaReaderView( context: context, @@ -806,13 +803,8 @@ class _MangaChapterPageGalleryState ReaderBottomBar( chapter: chapter, isVisible: _isView, - hasPreviousChapter: - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ), - hasNextChapter: - _readerController.getChapterIndex().$1 != 0, + hasPreviousChapter: _readerController.hasPreviousChapter, + hasNextChapter: _readerController.hasNextChapter, onPreviousChapter: () => _goToChapter(false), onNextChapter: () => _goToChapter(true), onSliderChanged: (value, ref) { From bf25129b5611900de2cd16718455bf6c2a063937 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:12:54 +0200 Subject: [PATCH 24/30] Reduce Code Duplication in novel_reader_view.dart `ReaderKeyboardHandler` already does everything here. --- lib/modules/novel/novel_reader_view.dart | 85 ++++++++---------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index d5d02c34..df62e602 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -12,6 +12,7 @@ import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/anime/widgets/desktop.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/reader_gestures.dart'; import 'package:mangayomi/modules/manga/reader/widgets/auto_scroll_button.dart'; import 'package:mangayomi/modules/manga/reader/widgets/reader_app_bar.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; @@ -233,61 +234,35 @@ class _NovelWebViewState extends ConsumerState ); } + /// Goes to either next or previous chapter + /// + /// The [next] parameter determines the navigation direction: + /// - `true` -> navigate to next chapter + /// - `false` -> navigate to previous chapter + /// + /// If the reader is already at the first or last chapter (depending on + /// the direction), the method returns without navigating. + void _goToChapter(bool next) { + if (next && !_readerController.hasNextChapter) return; + if (!next && !_readerController.hasPreviousChapter) return; + pushReplacementMangaReaderView( + context: context, + chapter: next + ? _readerController.getNextChapter() + : _readerController.getPrevChapter(), + ); + } + @override Widget build(BuildContext context) { final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - return KeyboardListener( - autofocus: true, - focusNode: FocusNode(), - onKeyEvent: (event) { - bool isLogicalKeyPressed(LogicalKeyboardKey key) => - HardwareKeyboard.instance.isLogicalKeyPressed(key); - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - final action = switch (event.logicalKey) { - LogicalKeyboardKey.f11 => - (!isLogicalKeyPressed(LogicalKeyboardKey.f11)) - ? _setFullScreen() - : null, - LogicalKeyboardKey.escape => - (!isLogicalKeyPressed(LogicalKeyboardKey.escape)) - ? _goBack(context) - : null, - LogicalKeyboardKey.backspace => - (!isLogicalKeyPressed(LogicalKeyboardKey.backspace)) - ? _goBack(context) - : null, - LogicalKeyboardKey.keyN || LogicalKeyboardKey.pageDown => - ((!isLogicalKeyPressed(LogicalKeyboardKey.keyN) || - !isLogicalKeyPressed(LogicalKeyboardKey.pageDown))) - ? switch (hasNextChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ), - _ => null, - } - : null, - LogicalKeyboardKey.keyP || LogicalKeyboardKey.pageUp => - ((!isLogicalKeyPressed(LogicalKeyboardKey.keyP) || - !isLogicalKeyPressed(LogicalKeyboardKey.pageUp))) - ? switch (hasPrevChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ), - _ => null, - } - : null, - _ => null, - }; - action; - }, + return ReaderKeyboardHandler( + onEscape: () => _goBack(context), + onFullScreen: () => _setFullScreen(), + onNextChapter: () => _goToChapter(true), + onPreviousChapter: () => _goToChapter(false), + ).wrapWithKeyboardListener( child: NotificationListener( onNotification: (notification) { if (notification.direction == ScrollDirection.idle) { @@ -970,12 +945,8 @@ class _NovelWebViewState extends ConsumerState if (!_isView && Platform.isIOS) { return const SizedBox.shrink(); } - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; + bool hasPrevChapter = _readerController.hasPreviousChapter; + bool hasNextChapter = _readerController.hasNextChapter; final bodyLargeColor = Theme.of(context).textTheme.bodyLarge!.color; return Positioned( bottom: 0, From 262fb4479291a703f5bbb0a49459f002c3645b33 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:26:01 +0200 Subject: [PATCH 25/30] Add Copilot suggested change https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3144115200 --- lib/modules/manga/detail/manga_detail_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 6ca32f5c..cbe85b12 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -115,7 +115,6 @@ class _MangaDetailViewState extends ConsumerState ref.watch(chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!)); ref.watch(chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!)); ref.watch(chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!)); - ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)); final chapters = ref.watch( getChaptersStreamProvider(mangaId: widget.manga!.id!), ); From 64d22741a55fdfbc91dfa140bbd7ee461db2c45e Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:33:14 +0200 Subject: [PATCH 26/30] Add Copilot suggested change https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3144115131 --- .../manga/reader/mixins/chapter_controller_mixin.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart index 3b27f726..2e35c6d0 100644 --- a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -33,13 +33,13 @@ mixin ChapterControllerMixin { // (which is more efficient since incognito status never changes mid-session). bool get incognitoMode => isar.settings.getSync(227)!.incognitoMode!; - bool get hasNextChapter => getChapterIndex().$1 != 0; - - bool get hasPreviousChapter { + bool get hasNextChapter { final idx = getChapterIndex(); - return idx.$1 + 1 != getChaptersLength(idx.$2); + return idx.$1 < getChaptersLength(idx.$2) - 1; } + bool get hasPreviousChapter => getChapterIndex().$1 > 0; + Settings getIsarSetting() => isar.settings.getSync(227)!; String getMangaName() => getManga().name!; From 49eed6405ec8aa642b066e3410cd0d8bad2274ac Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:41:35 +0200 Subject: [PATCH 27/30] Add Copilot suggested change https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3144115192 --- lib/modules/manga/detail/manga_detail_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index cbe85b12..6e0e637f 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -129,7 +129,7 @@ class _MangaDetailViewState extends ConsumerState return true; }, child: chapters.when( - data: (data) { + data: (_) { List chapters = widget.manga!.getFilteredChapterList(); ref.read(chaptersListttStateProvider.notifier).set(chapters); return _buildWidget(chapters: chapters); From ad4207da8280935bb4755c230f50e9dbce35a947 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:57:23 +0200 Subject: [PATCH 28/30] Add Copilot suggested change https://github.com/kodjodevf/mangayomi/pull/714#discussion_r3144115142 --- lib/modules/manga/reader/reader_view.dart | 3 ++- lib/modules/manga/reader/widgets/reader_bottom_bar.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 2440cda7..a0308eb1 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -373,7 +373,8 @@ class _MangaChapterPageGalleryState final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); final readerMode = ref.watch(_currentReaderMode); - final bool isHorizontalContinuous = readerMode!.isHorizontalContinuous; + if (readerMode == null) return const SizedBox.shrink(); + final bool isHorizontalContinuous = readerMode.isHorizontalContinuous; final l10n = l10nLocalizations(context)!; return ReaderKeyboardHandler( diff --git a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart index 3ef07d11..e75e7b6f 100644 --- a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart @@ -107,7 +107,8 @@ class ReaderBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final readerMode = ref.watch(currentReaderModeProvider); - final isHorizontalContinuous = readerMode!.isHorizontalContinuous; + if (readerMode == null) return const SizedBox.shrink(); + final isHorizontalContinuous = readerMode.isHorizontalContinuous; return Positioned( bottom: 0, From 8d5aa05952cbcfb02f7027b98a6940472f5c7f33 Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:07:25 +0200 Subject: [PATCH 29/30] Fix potential focus flicker `wrapWithKeyboardListener` creates a `FocusNode()` internally on every call. If no `focusNode` is passed, a new one is allocated every rebuild, which can cause focus flicker. Without this fix, keyboard focus can be intermittently lost after widget rebuilds, which would silently swallow keyboard shortcuts. --- lib/modules/manga/reader/reader_view.dart | 3 +++ lib/modules/novel/novel_reader_view.dart | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index a0308eb1..d9eab7e3 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -176,6 +176,7 @@ class _MangaChapterPageGalleryState _currentPageDisplayIndex.dispose(); _scrollIdleTimer?.cancel(); _isScrolling.dispose(); + _keyboardFocusNode.dispose(); _itemPositionsListener.itemPositions.removeListener(_readProgressListener); _photoViewController.dispose(); _photoViewScaleStateController.dispose(); @@ -298,6 +299,7 @@ class _MangaChapterPageGalleryState final _currentReaderMode = StateProvider(() => null); PageMode? _pageMode; bool _isView = false; + final _keyboardFocusNode = FocusNode(); /// Cached reader mode to safely access in dispose without ref.read() ReaderMode? _cachedReaderMode; @@ -395,6 +397,7 @@ class _MangaChapterPageGalleryState onPreviousChapter: () => _goToChapter(false), ).wrapWithKeyboardListener( isReverseHorizontal: _isReverseHorizontal, + focusNode: _keyboardFocusNode, child: NotificationListener( onNotification: (notification) { if (notification.direction == ScrollDirection.idle) { diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index df62e602..6b18109f 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -102,6 +102,7 @@ class _NovelWebViewState extends ConsumerState _autoScroll.value = false; _autoScroll.dispose(); _autoScrollPage.dispose(); + _keyboardFocusNode.dispose(); _ttsIndexSub?.cancel(); _ttsStateSub?.cancel(); _ttsWordSub?.cancel(); @@ -168,7 +169,7 @@ class _NovelWebViewState extends ConsumerState late bool _isBookmarked = _readerController.getChapterBookmarked(); bool _isView = false; - + final _keyboardFocusNode = FocusNode(); bool _showTts = false; String? _currentHtmlContent; final ValueNotifier<({int paragraph, int wordStart, int wordEnd})> @@ -782,6 +783,7 @@ class _NovelWebViewState extends ConsumerState ), ), ), + focusNode: _keyboardFocusNode, ); } From 3051c0ab97efb978acc310ddae8e739c209ac2ea Mon Sep 17 00:00:00 2001 From: NBA2K1 <78034913+NBA2K1@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:58:38 +0200 Subject: [PATCH 30/30] Fix bug Fix a rare bug in the chapter list. --- lib/utils/extensions/manga_extensions.dart | 48 +++++++++++++--------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/lib/utils/extensions/manga_extensions.dart b/lib/utils/extensions/manga_extensions.dart index 1041c737..7d17a9ba 100644 --- a/lib/utils/extensions/manga_extensions.dart +++ b/lib/utils/extensions/manga_extensions.dart @@ -33,12 +33,21 @@ extension MangaExtensions on Manga { .type!; final scanlators = settings.filterScanlatorList ?? []; - final filter = scanlators.where((e) => e.mangaId == id).toList(); + final filter = scanlators.where((e) => e.mangaId == id); final filterScanlator = filter.firstOrNull?.scanlators ?? []; + final recognition = ChapterRecognition(); + final mangaTitle = name ?? ''; - // Canonical ascending order (ch1 ... chN) — reader always moves forward. - final data = chapters - .toList(); // keep DB/insertion order, assumed ascending + // Memoize so each chapter name is parsed at most once during the sort. + final numCache = {}; + int chapNum(Chapter c) => numCache[c.id] ??= recognition.parseChapterNumber( + mangaTitle, + c.name ?? '', + ); + + // Sort by chapter number — DB insertion order is NOT guaranteed to be ascending + final data = chapters.toList() + ..sort((a, b) => chapNum(a).compareTo(chapNum(b))); final chapterIds = data.map((c) => c.id).whereType().toList(); final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty) @@ -83,32 +92,29 @@ extension MangaExtensions on Manga { settings.sortChapterList!.where((e) => e.mangaId == id).firstOrNull ?? SortChapter(mangaId: id, index: 1); final sortIndex = sortChapterEntry.index!; - final reverse = !sortChapterEntry.reverse!; + final reverse = sortChapterEntry.reverse!; // Start from the reading list so filter logic lives in one place. List list = getChapterListForReading(); - // Cache recognition instance — parseChapterNumber is called O(n log n) - // times during sort, so avoid constructing it inside the comparator. - final recognition = ChapterRecognition(); - final mangaTitle = name ?? ''; - - // Returns the parsed chapter number for a chapter, used as the primary - // numeric sort key for cases 0 and 1. - int chapNum(Chapter c) => - recognition.parseChapterNumber(mangaTitle, c.name ?? ''); - switch (sortIndex) { case 0: // by scanlator, then chapter number + // Cache recognition instance — parseChapterNumber is called O(n log n) + // times during sort, so avoid constructing it inside the comparator. + final recognition = ChapterRecognition(); + final mangaTitle = name ?? ''; + + // Returns the parsed chapter number for a chapter, used as the primary + // numeric sort key for cases 0 and 1. + final numCache = {}; + int chapNum(Chapter c) => numCache[c.id] ??= recognition + .parseChapterNumber(mangaTitle, c.name ?? ''); list.sort((a, b) { final s = (a.scanlator ?? '').compareTo(b.scanlator ?? ''); if (s != 0) return s; return chapNum(a).compareTo(chapNum(b)); }); break; - case 1: // by chapter number - list.sort((a, b) => chapNum(a).compareTo(chapNum(b))); - break; case 2: // by upload date list.sort((a, b) { if (a.dateUpload == null || b.dateUpload == null) return 0; @@ -123,8 +129,12 @@ extension MangaExtensions on Manga { return a.name!.compareTo(b.name!); }); break; + case 1: + default: + // getChapterListForReading already sorted by chapter number; nothing to do. + break; } - return reverse ? list.reversed.toList() : list; + return reverse ? list : list.reversed.toList(); } }