feat(reader): implement bidirectional chapter preloading and enhance memory management

This commit is contained in:
Moustapha Kodjo Amadou 2026-03-04 22:30:55 +01:00
parent 2f0fc85316
commit 87028ea8f5
3 changed files with 264 additions and 54 deletions

View file

@ -6,6 +6,10 @@ import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
import 'package:mangayomi/services/get_chapter_pages.dart'; import 'package:mangayomi/services/get_chapter_pages.dart';
/// Manages the preloading and memory of chapters in the manga reader. /// 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 { class ChapterPreloadManager {
/// The list of preloaded chapter data /// The list of preloaded chapter data
final List<UChapDataPreload> _pages = []; final List<UChapDataPreload> _pages = [];
@ -19,8 +23,9 @@ class ChapterPreloadManager {
/// Current reading index /// Current reading index
int _currentIndex = 0; int _currentIndex = 0;
/// Flag to prevent concurrent preloading /// Separate flags to allow concurrent prev/next preloading
bool _isPreloading = false; bool _isPreloadingNext = false;
bool _isPreloadingPrev = false;
/// Callbacks /// Callbacks
void Function()? onPagesUpdated; void Function()? onPagesUpdated;
@ -37,6 +42,12 @@ class ChapterPreloadManager {
/// Gets the loaded chapter count /// Gets the loaded chapter count
int get loadedChapterCount => _loadedChapterIds.length; 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 /// Sets the current reading index
set currentIndex(int value) { set currentIndex(int value) {
if (value >= 0 && value < _pages.length) { 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. /// Initializes the manager with the first chapter's pages.
void initialize(List<UChapDataPreload> initialPages, int startIndex) { void initialize(List<UChapDataPreload> initialPages, int startIndex) {
_pages.clear(); _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. /// Returns true if preloading was successful, false otherwise.
Future<bool> preloadNextChapter( Future<bool> preloadNextChapter(
GetChapterPagesModel chapterData, GetChapterPagesModel chapterData,
Chapter currentChapter, Chapter currentChapter,
) async { ) async {
if (_isPreloading) { if (_isPreloadingNext) {
if (kDebugMode) { if (kDebugMode) {
debugPrint('[ChapterPreload] Already preloading, skipping'); debugPrint('[ChapterPreload] Already preloading next, skipping');
} }
return false; return false;
} }
_isPreloading = true; _isPreloadingNext = true;
try { try {
if (chapterData.uChapDataPreload.isEmpty) { if (chapterData.uChapDataPreload.isEmpty) {
if (kDebugMode) { if (kDebugMode) {
debugPrint('[ChapterPreload] No pages in chapter data'); debugPrint('[ChapterPreload] No pages in next chapter data');
} }
return false; return false;
} }
@ -120,7 +139,9 @@ class ChapterPreloadManager {
final chapterId = _getChapterIdentifier(firstPage.chapter); final chapterId = _getChapterIdentifier(firstPage.chapter);
if (chapterId != null && _loadedChapterIds.contains(chapterId)) { if (chapterId != null && _loadedChapterIds.contains(chapterId)) {
if (kDebugMode) { if (kDebugMode) {
debugPrint('[ChapterPreload] Chapter already loaded: $chapterId'); debugPrint(
'[ChapterPreload] Next chapter already loaded: $chapterId',
);
} }
return false; return false;
} }
@ -155,7 +176,7 @@ class ChapterPreloadManager {
if (kDebugMode) { if (kDebugMode) {
debugPrint( debugPrint(
'[ChapterPreload] Added ${newPages.length} pages from next chapter', '[ChapterPreload] Appended ${newPages.length} pages from next chapter',
); );
debugPrint( debugPrint(
'[ChapterPreload] Total pages: ${_pages.length}, Chapters: ${_loadedChapterIds.length}', '[ChapterPreload] Total pages: ${_pages.length}, Chapters: ${_loadedChapterIds.length}',
@ -164,7 +185,108 @@ class ChapterPreloadManager {
return true; return true;
} finally { } 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<int> 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;
} }
} }

View file

@ -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<int> 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. /// Adds a "last chapter" transition page.
/// ///
/// Returns `true` if added successfully, `false` if already added. /// Returns `true` if added successfully, `false` if already added.

View file

@ -253,6 +253,11 @@ class _MangaChapterPageGalleryState
// final double _horizontalScaleValue = 1.0; // final double _horizontalScaleValue = 1.0;
bool _isNextChapterPreloading = false; 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 int pagePreloadAmount = ref.read(pagePreloadAmountStateProvider);
late bool _isBookmarked = _readerController.getChapterBookmarked(); late bool _isBookmarked = _readerController.getChapterBookmarked();
@ -912,6 +917,7 @@ class _MangaChapterPageGalleryState
} }
void _readProgressListener() async { void _readProgressListener() async {
if (_isAdjustingScroll) return;
final itemPositions = _itemPositionsListener.itemPositions.value; final itemPositions = _itemPositionsListener.itemPositions.value;
if (itemPositions.isNotEmpty) { if (itemPositions.isNotEmpty) {
_currentIndex = itemPositions.first.index; _currentIndex = itemPositions.first.index;
@ -942,32 +948,18 @@ class _MangaChapterPageGalleryState
}); });
} }
} }
if ((itemPositions.last.index == pagesLength - 1) &&
!_isLastPageTransition) { // Next-chapter preloading: trigger when near the end
if (_isNextChapterPreloading) return; final distToEnd = pagesLength - 1 - itemPositions.last.index;
try { if (distToEnd <= pagePreloadAmount && !_isLastPageTransition) {
_isNextChapterPreloading = true; _triggerNextChapterPreload();
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);
}
} }
// Previous-chapter preloading: trigger when near the start
if (itemPositions.first.index <= pagePreloadAmount) {
_triggerPrevChapterPreload();
}
final idx = pages[_currentIndex!].index; final idx = pages[_currentIndex!].index;
if (idx != null) { if (idx != null) {
ref.read(currentIndexProvider(chapter).notifier).setCurrentIndex(idx); ref.read(currentIndexProvider(chapter).notifier).setCurrentIndex(idx);
@ -1007,6 +999,90 @@ class _MangaChapterPageGalleryState
} catch (_) {} } 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 { void _initCurrentIndex() async {
final readerMode = _readerController.getReaderMode(); final readerMode = _readerController.getReaderMode();
@ -1019,6 +1095,9 @@ class _MangaChapterPageGalleryState
}, },
); );
// proactively start loading adjacent chapters in background
_proactivePreload();
_readerController.setMangaHistoryUpdate(); _readerController.setMangaHistoryUpdate();
// Use post-frame callback instead of Future.delayed(1ms) timing hack // Use post-frame callback instead of Future.delayed(1ms) timing hack
await Future(() {}); await Future(() {});
@ -1087,25 +1166,15 @@ class _MangaChapterPageGalleryState
.setCurrentIndex(pages[index].index!); .setCurrentIndex(pages[index].index!);
} }
if ((pages[index].pageIndex! == pages.length - 1) && // Next-chapter preloading: trigger when near the end
!_isLastPageTransition) { final distToEnd = pages.length - 1 - index;
if (_isNextChapterPreloading) return; if (distToEnd <= pagePreloadAmount && !_isLastPageTransition) {
try { _triggerNextChapterPreload();
_isNextChapterPreloading = true; }
if (!mounted) return;
final value = await ref.watch( // Previous-chapter preloading: trigger when near the start
getChapterPagesProvider( if (index <= pagePreloadAmount) {
chapter: _readerController.getNextChapter(), _triggerPrevChapterPreload();
).future,
);
if (mounted) {
_preloadNextChapter(value, chapter);
_isNextChapterPreloading = false;
}
} on RangeError {
_isNextChapterPreloading = false;
_addLastPageTransition(chapter);
}
} }
} }