mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-03-11 17:25:32 +00:00
feat(reader): implement bidirectional chapter preloading and enhance memory management
This commit is contained in:
parent
2f0fc85316
commit
87028ea8f5
3 changed files with 264 additions and 54 deletions
|
|
@ -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<UChapDataPreload> _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<UChapDataPreload> 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<bool> 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// Returns `true` if added successfully, `false` if already added.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue