From 87028ea8f599204ac3a81d39a3e2784b72b52d9b Mon Sep 17 00:00:00 2001 From: Moustapha Kodjo Amadou <107993382+kodjodevf@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:30:55 +0100 Subject: [PATCH] feat(reader): implement bidirectional chapter preloading and enhance memory management --- .../managers/chapter_preload_manager.dart | 142 ++++++++++++++-- .../mixins/reader_memory_management.dart | 19 +++ lib/modules/manga/reader/reader_view.dart | 157 +++++++++++++----- 3 files changed, 264 insertions(+), 54 deletions(-) diff --git a/lib/modules/manga/reader/managers/chapter_preload_manager.dart b/lib/modules/manga/reader/managers/chapter_preload_manager.dart index b9d3a883..2be7343d 100644 --- a/lib/modules/manga/reader/managers/chapter_preload_manager.dart +++ b/lib/modules/manga/reader/managers/chapter_preload_manager.dart @@ -6,6 +6,10 @@ import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart'; import 'package:mangayomi/services/get_chapter_pages.dart'; /// Manages the preloading and memory of chapters in the manga reader. +/// +/// Supports bidirectional preloading (previous + next chapters) following +/// adjacent chapters are loaded proactively and their +/// pages are seamlessly merged into the reader's page list. class ChapterPreloadManager { /// The list of preloaded chapter data final List _pages = []; @@ -19,8 +23,9 @@ class ChapterPreloadManager { /// Current reading index int _currentIndex = 0; - /// Flag to prevent concurrent preloading - bool _isPreloading = false; + /// Separate flags to allow concurrent prev/next preloading + bool _isPreloadingNext = false; + bool _isPreloadingPrev = false; /// Callbacks void Function()? onPagesUpdated; @@ -37,6 +42,12 @@ class ChapterPreloadManager { /// Gets the loaded chapter count int get loadedChapterCount => _loadedChapterIds.length; + /// Whether a previous chapter preload is in progress. + bool get isPreloadingPrev => _isPreloadingPrev; + + /// Whether a next chapter preload is in progress. + bool get isPreloadingNext => _isPreloadingNext; + /// Sets the current reading index set currentIndex(int value) { if (value >= 0 && value < _pages.length) { @@ -44,6 +55,12 @@ class ChapterPreloadManager { } } + /// Returns `true` if pages from [chapter] are already in memory. + bool isChapterLoaded(Chapter? chapter) { + final id = _getChapterIdentifier(chapter); + return id != null && _loadedChapterIds.contains(id); + } + /// Initializes the manager with the first chapter's pages. void initialize(List initialPages, int startIndex) { _pages.clear(); @@ -85,26 +102,28 @@ class ChapterPreloadManager { ); } - /// Preloads the next chapter's pages. + // ── Next-chapter preloading (append) ── + + /// Preloads the next chapter's pages by appending them. /// /// Returns true if preloading was successful, false otherwise. Future preloadNextChapter( GetChapterPagesModel chapterData, Chapter currentChapter, ) async { - if (_isPreloading) { + if (_isPreloadingNext) { if (kDebugMode) { - debugPrint('[ChapterPreload] Already preloading, skipping'); + debugPrint('[ChapterPreload] Already preloading next, skipping'); } return false; } - _isPreloading = true; + _isPreloadingNext = true; try { if (chapterData.uChapDataPreload.isEmpty) { if (kDebugMode) { - debugPrint('[ChapterPreload] No pages in chapter data'); + debugPrint('[ChapterPreload] No pages in next chapter data'); } return false; } @@ -120,7 +139,9 @@ class ChapterPreloadManager { final chapterId = _getChapterIdentifier(firstPage.chapter); if (chapterId != null && _loadedChapterIds.contains(chapterId)) { if (kDebugMode) { - debugPrint('[ChapterPreload] Chapter already loaded: $chapterId'); + debugPrint( + '[ChapterPreload] Next chapter already loaded: $chapterId', + ); } return false; } @@ -155,7 +176,7 @@ class ChapterPreloadManager { if (kDebugMode) { debugPrint( - '[ChapterPreload] Added ${newPages.length} pages from next chapter', + '[ChapterPreload] Appended ${newPages.length} pages from next chapter', ); debugPrint( '[ChapterPreload] Total pages: ${_pages.length}, Chapters: ${_loadedChapterIds.length}', @@ -164,7 +185,108 @@ class ChapterPreloadManager { return true; } finally { - _isPreloading = false; + _isPreloadingNext = false; + } + } + + // ── Previous-chapter preloading (prepend) ── + + /// Preloads the previous chapter's pages by prepending them. + /// + /// Returns the number of pages prepended (including transition page), + /// or 0 if preloading was skipped / failed. + /// The caller **must** adjust the scroll / page index by the returned count. + Future preloadPrevChapter( + GetChapterPagesModel chapterData, + Chapter currentChapter, + ) async { + if (_isPreloadingPrev) { + if (kDebugMode) { + debugPrint('[ChapterPreload] Already preloading prev, skipping'); + } + return 0; + } + + _isPreloadingPrev = true; + + try { + if (chapterData.uChapDataPreload.isEmpty) { + if (kDebugMode) { + debugPrint('[ChapterPreload] No pages in prev chapter data'); + } + return 0; + } + + final firstPage = chapterData.uChapDataPreload.first; + if (firstPage.chapter == null) { + if (kDebugMode) { + debugPrint('[ChapterPreload] No chapter in prev first page'); + } + return 0; + } + + final chapterId = _getChapterIdentifier(firstPage.chapter); + if (chapterId != null && _loadedChapterIds.contains(chapterId)) { + if (kDebugMode) { + debugPrint( + '[ChapterPreload] Prev chapter already loaded: $chapterId', + ); + } + return 0; + } + + // Transition page: marks end of prev chapter → start of current chapter + final transitionPage = UChapDataPreload.transition( + currentChapter: firstPage.chapter!, + nextChapter: currentChapter, + mangaName: currentChapter.manga.value?.name ?? '', + pageIndex: 0, // recalculated below + ); + + // Build prepend list: prev chapter pages + transition page + final prevPages = chapterData.uChapDataPreload.toList(); + final prependList = [...prevPages, transitionPage]; + final prependCount = prependList.length; + + // Assign pageIndex to prepended pages (0 .. prependCount-1) + for (int i = 0; i < prependList.length; i++) { + prependList[i].pageIndex = i; + } + + // Shift pageIndex of all existing pages + for (int i = 0; i < _pages.length; i++) { + if (_pages[i].pageIndex != null) { + _pages[i].pageIndex = _pages[i].pageIndex! + prependCount; + } + } + + // Prepend to pages list + _pages.insertAll(0, prependList); + + // Update current index to account for prepended pages + _currentIndex += prependCount; + + // Track the new chapter + if (chapterId != null) { + _loadedChapterIds.add(chapterId); + _chapterLoadOrder.addFirst(chapterId); + } + + // Notify listeners + onPagesUpdated?.call(); + + if (kDebugMode) { + debugPrint( + '[ChapterPreload] Prepended ${prevPages.length} pages from prev chapter', + ); + debugPrint( + '[ChapterPreload] Total pages: ${_pages.length}, Chapters: ${_loadedChapterIds.length}', + ); + } + + return prependCount; + } finally { + _isPreloadingPrev = false; } } diff --git a/lib/modules/manga/reader/mixins/reader_memory_management.dart b/lib/modules/manga/reader/mixins/reader_memory_management.dart index 2cd6433f..fc332991 100644 --- a/lib/modules/manga/reader/mixins/reader_memory_management.dart +++ b/lib/modules/manga/reader/mixins/reader_memory_management.dart @@ -79,6 +79,25 @@ mixin ReaderMemoryManagement { ); } + /// Preloads the previous chapter by **prepending** its pages. + /// + /// Returns the number of pages prepended (> 0 means all existing indices + /// shifted by that amount). The caller must adjust the scroll / page index. + Future preloadPreviousChapter( + GetChapterPagesModel chapterData, + Chapter currentChapter, + ) async { + return await _preloadManager.preloadPrevChapter( + chapterData, + currentChapter, + ); + } + + /// Whether [chapter] pages are already loaded in the preload manager. + bool isChapterLoaded(Chapter? chapter) { + return _preloadManager.isChapterLoaded(chapter); + } + /// Adds a "last chapter" transition page. /// /// Returns `true` if added successfully, `false` if already added. diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 4b6f61cf..ef3208e9 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -253,6 +253,11 @@ class _MangaChapterPageGalleryState // final double _horizontalScaleValue = 1.0; bool _isNextChapterPreloading = false; + bool _isPrevChapterPreloading = false; + + /// Guard flag: suppresses [_readProgressListener] during scroll position + /// adjustment after prepending previous-chapter pages. + bool _isAdjustingScroll = false; late int pagePreloadAmount = ref.read(pagePreloadAmountStateProvider); late bool _isBookmarked = _readerController.getChapterBookmarked(); @@ -912,6 +917,7 @@ class _MangaChapterPageGalleryState } void _readProgressListener() async { + if (_isAdjustingScroll) return; final itemPositions = _itemPositionsListener.itemPositions.value; if (itemPositions.isNotEmpty) { _currentIndex = itemPositions.first.index; @@ -942,32 +948,18 @@ class _MangaChapterPageGalleryState }); } } - if ((itemPositions.last.index == pagesLength - 1) && - !_isLastPageTransition) { - if (_isNextChapterPreloading) return; - try { - _isNextChapterPreloading = true; - if (!mounted) return; - try { - final idx = pages[_currentIndex!].index; - if (idx != null) { - _readerController.setPageIndex(_geCurrentIndex(idx), false); - } - } catch (_) {} - final value = await ref.read( - getChapterPagesProvider( - chapter: _readerController.getNextChapter(), - ).future, - ); - if (mounted) { - _preloadNextChapter(value, chapter); - _isNextChapterPreloading = false; - } - } on RangeError { - _isNextChapterPreloading = false; - _addLastPageTransition(chapter); - } + + // ── Next-chapter preloading: trigger when near the end ── + final distToEnd = pagesLength - 1 - itemPositions.last.index; + if (distToEnd <= pagePreloadAmount && !_isLastPageTransition) { + _triggerNextChapterPreload(); } + + // ── Previous-chapter preloading: trigger when near the start ── + if (itemPositions.first.index <= pagePreloadAmount) { + _triggerPrevChapterPreload(); + } + final idx = pages[_currentIndex!].index; if (idx != null) { ref.read(currentIndexProvider(chapter).notifier).setCurrentIndex(idx); @@ -1007,6 +999,90 @@ class _MangaChapterPageGalleryState } catch (_) {} } + // bidirectional proactive chapter preloading ── + + /// Proactively starts loading both adjacent chapters at reader init. + void _proactivePreload() { + _triggerNextChapterPreload(); + _triggerPrevChapterPreload(); + } + + /// Fires off next-chapter page fetching if not already in progress. + void _triggerNextChapterPreload() async { + if (_isNextChapterPreloading || _isLastPageTransition) return; + _isNextChapterPreloading = true; + try { + if (!mounted) return; + final nextChapter = _readerController.getNextChapter(); + if (isChapterLoaded(nextChapter)) { + _isNextChapterPreloading = false; + return; + } + final value = await ref.read( + getChapterPagesProvider(chapter: nextChapter).future, + ); + if (mounted) { + _preloadNextChapter(value, chapter); + } + _isNextChapterPreloading = false; + } on RangeError { + _isNextChapterPreloading = false; + _addLastPageTransition(chapter); + } catch (_) { + _isNextChapterPreloading = false; + } + } + + /// Fires off previous-chapter page fetching and prepends pages. + void _triggerPrevChapterPreload() async { + if (_isPrevChapterPreloading) return; + _isPrevChapterPreloading = true; + try { + if (!mounted) return; + final prevChapter = _readerController.getPrevChapter(); + if (isChapterLoaded(prevChapter)) { + _isPrevChapterPreloading = false; + return; + } + final value = await ref.read( + getChapterPagesProvider(chapter: prevChapter).future, + ); + if (mounted) { + _handlePrevChapterPrepended(value, chapter); + } + } on RangeError { + // No previous chapter — nothing to prepend + } catch (_) {} + _isPrevChapterPreloading = false; + } + + /// Prepends previous-chapter pages and adjusts scroll position to avoid jump. + void _handlePrevChapterPrepended( + GetChapterPagesModel chapterData, + Chapter chap, + ) { + try { + if (chapterData.uChapDataPreload.isEmpty || !mounted) return; + preloadPreviousChapter(chapterData, chap).then((prependCount) { + if (prependCount > 0 && mounted) { + _isAdjustingScroll = true; + _currentIndex = _currentIndex! + prependCount; + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + if (_isContinuousMode()) { + _itemScrollController.jumpTo(index: _currentIndex!); + } else if (_extendedController.hasClients) { + _extendedController.jumpToPage(_currentIndex!); + } + _isAdjustingScroll = false; + } + }); + } + }); + } catch (_) {} + } + void _initCurrentIndex() async { final readerMode = _readerController.getReaderMode(); @@ -1019,6 +1095,9 @@ class _MangaChapterPageGalleryState }, ); + // proactively start loading adjacent chapters in background + _proactivePreload(); + _readerController.setMangaHistoryUpdate(); // Use post-frame callback instead of Future.delayed(1ms) timing hack await Future(() {}); @@ -1087,25 +1166,15 @@ class _MangaChapterPageGalleryState .setCurrentIndex(pages[index].index!); } - if ((pages[index].pageIndex! == pages.length - 1) && - !_isLastPageTransition) { - if (_isNextChapterPreloading) return; - try { - _isNextChapterPreloading = true; - if (!mounted) return; - final value = await ref.watch( - getChapterPagesProvider( - chapter: _readerController.getNextChapter(), - ).future, - ); - if (mounted) { - _preloadNextChapter(value, chapter); - _isNextChapterPreloading = false; - } - } on RangeError { - _isNextChapterPreloading = false; - _addLastPageTransition(chapter); - } + // ── Next-chapter preloading: trigger when near the end ── + final distToEnd = pages.length - 1 - index; + if (distToEnd <= pagePreloadAmount && !_isLastPageTransition) { + _triggerNextChapterPreload(); + } + + // ── Previous-chapter preloading: trigger when near the start ── + if (index <= pagePreloadAmount) { + _triggerPrevChapterPreload(); } }