mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-03-11 21:35:32 +00:00
feat: Implement virtual scrolling for manga reader with optimized page management
- Added VirtualMangaList widget for displaying manga pages in a virtual scrolling list. - Introduced VirtualPageManager to handle page loading, caching, and memory optimization. - Created VirtualReaderView to integrate virtual scrolling with PhotoView for enhanced reading experience. - Implemented page loading states and memory cleanup mechanisms in VirtualPageManager. - Added debug information overlay for monitoring virtual page manager statistics. - Enhanced user experience with callbacks for chapter transitions and page changes.
This commit is contained in:
parent
c3ac07fa97
commit
c24df38506
4 changed files with 1341 additions and 560 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,343 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||||
|
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/double_columm_view_vertical.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
|
/// Widget for displaying manga pages in a virtual scrolling list
|
||||||
|
class VirtualMangaList extends ConsumerStatefulWidget {
|
||||||
|
final VirtualPageManager pageManager;
|
||||||
|
final ItemScrollController itemScrollController;
|
||||||
|
final ScrollOffsetController scrollOffsetController;
|
||||||
|
final ItemPositionsListener itemPositionsListener;
|
||||||
|
final Axis scrollDirection;
|
||||||
|
final double minCacheExtent;
|
||||||
|
final int initialScrollIndex;
|
||||||
|
final ScrollPhysics physics;
|
||||||
|
final Function(reader.UChapDataPreload data) onLongPressData;
|
||||||
|
final Function(bool) onFailedToLoadImage;
|
||||||
|
final BackgroundColor backgroundColor;
|
||||||
|
final bool isDoublePageMode;
|
||||||
|
final bool isHorizontalContinuous;
|
||||||
|
final ReaderMode readerMode;
|
||||||
|
final Function(Offset) onDoubleTapDown;
|
||||||
|
final VoidCallback onDoubleTap;
|
||||||
|
final Function(Chapter chapter)? onChapterChanged;
|
||||||
|
final Function(int lastPageIndex)? onReachedLastPage;
|
||||||
|
final Function(int index)? onPageChanged;
|
||||||
|
|
||||||
|
const VirtualMangaList({
|
||||||
|
super.key,
|
||||||
|
required this.pageManager,
|
||||||
|
required this.itemScrollController,
|
||||||
|
required this.scrollOffsetController,
|
||||||
|
required this.itemPositionsListener,
|
||||||
|
required this.scrollDirection,
|
||||||
|
required this.minCacheExtent,
|
||||||
|
required this.initialScrollIndex,
|
||||||
|
required this.physics,
|
||||||
|
required this.onLongPressData,
|
||||||
|
required this.onFailedToLoadImage,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.isDoublePageMode,
|
||||||
|
required this.isHorizontalContinuous,
|
||||||
|
required this.readerMode,
|
||||||
|
required this.onDoubleTapDown,
|
||||||
|
required this.onDoubleTap,
|
||||||
|
this.onChapterChanged,
|
||||||
|
this.onReachedLastPage,
|
||||||
|
this.onPageChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<VirtualMangaList> createState() => _VirtualMangaListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VirtualMangaListState extends ConsumerState<VirtualMangaList> {
|
||||||
|
Chapter? _currentChapter;
|
||||||
|
int? _currentIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Listen to item positions to update virtual page manager
|
||||||
|
widget.itemPositionsListener.itemPositions.addListener(_onPositionChanged);
|
||||||
|
|
||||||
|
// Initialize current chapter
|
||||||
|
if (widget.pageManager.pageCount > 0) {
|
||||||
|
final firstPage = widget.pageManager.getOriginalPage(
|
||||||
|
widget.initialScrollIndex,
|
||||||
|
);
|
||||||
|
_currentChapter = firstPage?.chapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.itemPositionsListener.itemPositions.removeListener(
|
||||||
|
_onPositionChanged,
|
||||||
|
);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPositionChanged() {
|
||||||
|
final positions = widget.itemPositionsListener.itemPositions.value;
|
||||||
|
if (positions.isNotEmpty) {
|
||||||
|
// Get the first visible item
|
||||||
|
final firstVisibleIndex = positions.first.index;
|
||||||
|
final lastVisibleIndex = positions.last.index;
|
||||||
|
|
||||||
|
// Update virtual page manager
|
||||||
|
widget.pageManager.updateVisibleIndex(firstVisibleIndex);
|
||||||
|
|
||||||
|
// Calculate actual page lengths considering page mode
|
||||||
|
int pagesLength =
|
||||||
|
widget.isDoublePageMode && !widget.isHorizontalContinuous
|
||||||
|
? (widget.pageManager.pageCount / 2).ceil() + 1
|
||||||
|
: widget.pageManager.pageCount;
|
||||||
|
|
||||||
|
// Check if index is valid
|
||||||
|
if (firstVisibleIndex >= 0 && firstVisibleIndex < pagesLength) {
|
||||||
|
final currentPage = widget.pageManager.getOriginalPage(
|
||||||
|
firstVisibleIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentPage != null) {
|
||||||
|
// Check for chapter change
|
||||||
|
if (_currentChapter?.id != currentPage.chapter?.id &&
|
||||||
|
currentPage.chapter != null) {
|
||||||
|
_currentChapter = currentPage.chapter;
|
||||||
|
widget.onChapterChanged?.call(currentPage.chapter!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current index
|
||||||
|
if (_currentIndex != firstVisibleIndex) {
|
||||||
|
_currentIndex = firstVisibleIndex;
|
||||||
|
widget.onPageChanged?.call(firstVisibleIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reached last page to trigger next chapter preload
|
||||||
|
if (lastVisibleIndex >= pagesLength - 1) {
|
||||||
|
widget.onReachedLastPage?.call(lastVisibleIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: widget.pageManager,
|
||||||
|
builder: (context, child) {
|
||||||
|
final itemCount =
|
||||||
|
widget.isDoublePageMode && !widget.isHorizontalContinuous
|
||||||
|
? (widget.pageManager.pageCount / 2).ceil() + 1
|
||||||
|
: widget.pageManager.pageCount;
|
||||||
|
|
||||||
|
return ScrollablePositionedList.separated(
|
||||||
|
scrollDirection: widget.scrollDirection,
|
||||||
|
minCacheExtent: widget.minCacheExtent,
|
||||||
|
initialScrollIndex: widget.initialScrollIndex,
|
||||||
|
itemCount: itemCount,
|
||||||
|
physics: widget.physics,
|
||||||
|
itemScrollController: widget.itemScrollController,
|
||||||
|
scrollOffsetController: widget.scrollOffsetController,
|
||||||
|
itemPositionsListener: widget.itemPositionsListener,
|
||||||
|
itemBuilder: (context, index) => _buildItem(context, index),
|
||||||
|
separatorBuilder: _buildSeparator,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, int index) {
|
||||||
|
if (widget.isDoublePageMode && !widget.isHorizontalContinuous) {
|
||||||
|
return _buildDoublePageItem(context, index);
|
||||||
|
} else {
|
||||||
|
return _buildSinglePageItem(context, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSinglePageItem(BuildContext context, int index) {
|
||||||
|
final originalPage = widget.pageManager.getOriginalPage(index);
|
||||||
|
if (originalPage == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if page should be loaded
|
||||||
|
final pageInfo = widget.pageManager.getPageInfo(index);
|
||||||
|
final shouldLoad = widget.pageManager.shouldPageBeLoaded(index);
|
||||||
|
|
||||||
|
if (!shouldLoad &&
|
||||||
|
(pageInfo?.loadState == PageLoadState.notLoaded || pageInfo == null)) {
|
||||||
|
// Return placeholder for unloaded pages
|
||||||
|
return _buildPlaceholder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalPage.isTransitionPage) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onDoubleTapDown: (details) =>
|
||||||
|
widget.onDoubleTapDown(details.globalPosition),
|
||||||
|
onDoubleTap: widget.onDoubleTap,
|
||||||
|
child: TransitionViewVertical(data: originalPage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onDoubleTapDown: (details) =>
|
||||||
|
widget.onDoubleTapDown(details.globalPosition),
|
||||||
|
onDoubleTap: widget.onDoubleTap,
|
||||||
|
child: ImageViewVertical(
|
||||||
|
data: originalPage,
|
||||||
|
failedToLoadImage: widget.onFailedToLoadImage,
|
||||||
|
onLongPressData: widget.onLongPressData,
|
||||||
|
isHorizontal: widget.isHorizontalContinuous,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoublePageItem(BuildContext context, int index) {
|
||||||
|
if (index >= widget.pageManager.pageCount) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final int index1 = index * 2 - 1;
|
||||||
|
final int index2 = index1 + 1;
|
||||||
|
|
||||||
|
final List<reader.UChapDataPreload?> datas = index == 0
|
||||||
|
? [widget.pageManager.getOriginalPage(0), null]
|
||||||
|
: [
|
||||||
|
index1 < widget.pageManager.pageCount
|
||||||
|
? widget.pageManager.getOriginalPage(index1)
|
||||||
|
: null,
|
||||||
|
index2 < widget.pageManager.pageCount
|
||||||
|
? widget.pageManager.getOriginalPage(index2)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if pages should be loaded
|
||||||
|
final shouldLoad1 = index1 >= 0
|
||||||
|
? widget.pageManager.shouldPageBeLoaded(index1)
|
||||||
|
: false;
|
||||||
|
final shouldLoad2 = index2 < widget.pageManager.pageCount
|
||||||
|
? widget.pageManager.shouldPageBeLoaded(index2)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!shouldLoad1 && !shouldLoad2) {
|
||||||
|
return _buildPlaceholder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onDoubleTapDown: (details) =>
|
||||||
|
widget.onDoubleTapDown(details.globalPosition),
|
||||||
|
onDoubleTap: widget.onDoubleTap,
|
||||||
|
child: DoubleColummVerticalView(
|
||||||
|
datas: datas,
|
||||||
|
backgroundColor: widget.backgroundColor,
|
||||||
|
isFailedToLoadImage: widget.onFailedToLoadImage,
|
||||||
|
onLongPressData: widget.onLongPressData,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholder(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: context.height(0.8),
|
||||||
|
color: getBackgroundColor(widget.backgroundColor),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSeparator(BuildContext context, int index) {
|
||||||
|
if (widget.readerMode == ReaderMode.webtoon) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.isHorizontalContinuous) {
|
||||||
|
return VerticalDivider(
|
||||||
|
color: getBackgroundColor(widget.backgroundColor),
|
||||||
|
width: 6,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Divider(
|
||||||
|
color: getBackgroundColor(widget.backgroundColor),
|
||||||
|
height: 6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug widget to show virtual page manager statistics
|
||||||
|
class VirtualPageManagerDebugInfo extends ConsumerWidget {
|
||||||
|
final VirtualPageManager pageManager;
|
||||||
|
|
||||||
|
const VirtualPageManagerDebugInfo({super.key, required this.pageManager});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: pageManager,
|
||||||
|
builder: (context, child) {
|
||||||
|
final stats = pageManager.getMemoryStats();
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: 100,
|
||||||
|
right: 10,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Virtual Page Manager',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Current: ${stats['currentIndex']}/${stats['totalPages']}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Loaded: ${stats['loadedPages']}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Cached: ${stats['cachedPages']}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Errors: ${stats['errorPages']}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Queue: ${stats['preloadQueueSize']}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||||
|
|
||||||
|
/// Page loading states for virtual scrolling
|
||||||
|
enum PageLoadState { notLoaded, loading, loaded, error, cached }
|
||||||
|
|
||||||
|
/// Virtual page information for tracking state
|
||||||
|
class VirtualPageInfo {
|
||||||
|
final int index;
|
||||||
|
final reader.UChapDataPreload originalData;
|
||||||
|
PageLoadState loadState;
|
||||||
|
DateTime? lastAccessTime;
|
||||||
|
Object? error;
|
||||||
|
|
||||||
|
VirtualPageInfo({
|
||||||
|
required this.index,
|
||||||
|
required this.originalData,
|
||||||
|
this.loadState = PageLoadState.notLoaded,
|
||||||
|
this.lastAccessTime,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isVisible =>
|
||||||
|
loadState == PageLoadState.loaded || loadState == PageLoadState.cached;
|
||||||
|
bool get needsLoading => loadState == PageLoadState.notLoaded;
|
||||||
|
bool get isLoading => loadState == PageLoadState.loading;
|
||||||
|
bool get hasError => loadState == PageLoadState.error;
|
||||||
|
|
||||||
|
void markAccessed() {
|
||||||
|
lastAccessTime = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration get timeSinceAccess {
|
||||||
|
if (lastAccessTime == null) return Duration.zero;
|
||||||
|
return DateTime.now().difference(lastAccessTime!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for virtual page manager
|
||||||
|
class VirtualPageConfig {
|
||||||
|
final int preloadDistance;
|
||||||
|
final int maxCachedPages;
|
||||||
|
final Duration cacheTimeout;
|
||||||
|
final bool enableMemoryOptimization;
|
||||||
|
|
||||||
|
const VirtualPageConfig({
|
||||||
|
this.preloadDistance = 3,
|
||||||
|
this.maxCachedPages = 10,
|
||||||
|
this.cacheTimeout = const Duration(minutes: 5),
|
||||||
|
this.enableMemoryOptimization = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages virtual page loading and memory optimization
|
||||||
|
class VirtualPageManager extends ChangeNotifier {
|
||||||
|
final List<reader.UChapDataPreload> _originalPages;
|
||||||
|
final VirtualPageConfig config;
|
||||||
|
final Map<int, VirtualPageInfo> _pageInfoMap = {};
|
||||||
|
final Set<int> _preloadQueue = {};
|
||||||
|
|
||||||
|
int _currentVisibleIndex = 0;
|
||||||
|
Timer? _cleanupTimer;
|
||||||
|
|
||||||
|
VirtualPageManager({
|
||||||
|
required List<reader.UChapDataPreload> pages,
|
||||||
|
this.config = const VirtualPageConfig(),
|
||||||
|
}) : _originalPages = List.from(pages) {
|
||||||
|
_initializePages();
|
||||||
|
_startCleanupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializePages() {
|
||||||
|
for (int i = 0; i < _originalPages.length; i++) {
|
||||||
|
_pageInfoMap[i] = VirtualPageInfo(
|
||||||
|
index: i,
|
||||||
|
originalData: _originalPages[i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startCleanupTimer() {
|
||||||
|
_cleanupTimer?.cancel();
|
||||||
|
_cleanupTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
(_) => _performMemoryCleanup(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cleanupTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get page count
|
||||||
|
int get pageCount => _originalPages.length;
|
||||||
|
|
||||||
|
/// Get current visible index
|
||||||
|
int get currentVisibleIndex => _currentVisibleIndex;
|
||||||
|
|
||||||
|
/// Get page info for a specific index
|
||||||
|
VirtualPageInfo? getPageInfo(int index) {
|
||||||
|
if (index < 0 || index >= _originalPages.length) return null;
|
||||||
|
return _pageInfoMap[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get original page data
|
||||||
|
reader.UChapDataPreload? getOriginalPage(int index) {
|
||||||
|
if (index < 0 || index >= _originalPages.length) return null;
|
||||||
|
return _originalPages[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update visible page index and trigger preloading
|
||||||
|
void updateVisibleIndex(int index) {
|
||||||
|
if (index == _currentVisibleIndex) return;
|
||||||
|
|
||||||
|
_currentVisibleIndex = index.clamp(0, _originalPages.length - 1);
|
||||||
|
_pageInfoMap[_currentVisibleIndex]?.markAccessed();
|
||||||
|
|
||||||
|
_schedulePreloading();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a page should be visible/loaded
|
||||||
|
bool shouldPageBeLoaded(int index) {
|
||||||
|
final distance = (index - _currentVisibleIndex).abs();
|
||||||
|
return distance <= config.preloadDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get priority for a page (higher = more important)
|
||||||
|
int getPagePriority(int index) {
|
||||||
|
final distance = (index - _currentVisibleIndex).abs();
|
||||||
|
if (distance == 0) return 1000; // Current page has highest priority
|
||||||
|
return max(0, 100 - distance * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule preloading for nearby pages
|
||||||
|
void _schedulePreloading() {
|
||||||
|
_preloadQueue.clear();
|
||||||
|
|
||||||
|
// Add pages within preload distance
|
||||||
|
for (int i = 0; i < _originalPages.length; i++) {
|
||||||
|
if (shouldPageBeLoaded(i)) {
|
||||||
|
final pageInfo = _pageInfoMap[i]!;
|
||||||
|
if (pageInfo.needsLoading) {
|
||||||
|
_preloadQueue.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process preload queue
|
||||||
|
_processPreloadQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the preload queue
|
||||||
|
void _processPreloadQueue() {
|
||||||
|
final sortedQueue = _preloadQueue.toList()
|
||||||
|
..sort((a, b) => getPagePriority(b).compareTo(getPagePriority(a)));
|
||||||
|
|
||||||
|
for (final index in sortedQueue.take(3)) {
|
||||||
|
// Limit concurrent loading
|
||||||
|
_loadPage(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a specific page
|
||||||
|
Future<void> _loadPage(int index) async {
|
||||||
|
final pageInfo = _pageInfoMap[index];
|
||||||
|
if (pageInfo == null || pageInfo.isLoading) return;
|
||||||
|
|
||||||
|
pageInfo.loadState = PageLoadState.loading;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For now, we just mark as loaded since the actual image loading
|
||||||
|
// is handled by the ImageView widgets
|
||||||
|
await Future.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
pageInfo.loadState = PageLoadState.loaded;
|
||||||
|
pageInfo.markAccessed();
|
||||||
|
} catch (error) {
|
||||||
|
pageInfo.loadState = PageLoadState.error;
|
||||||
|
pageInfo.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform memory cleanup
|
||||||
|
void _performMemoryCleanup() {
|
||||||
|
if (!config.enableMemoryOptimization) return;
|
||||||
|
|
||||||
|
final pageEntries = _pageInfoMap.entries.toList();
|
||||||
|
|
||||||
|
// Sort by last access time and distance from current page
|
||||||
|
pageEntries.sort((a, b) {
|
||||||
|
final aDistance = (a.key - _currentVisibleIndex).abs();
|
||||||
|
final bDistance = (b.key - _currentVisibleIndex).abs();
|
||||||
|
final aTime =
|
||||||
|
a.value.lastAccessTime ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bTime =
|
||||||
|
b.value.lastAccessTime ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
// First sort by distance, then by access time
|
||||||
|
final distanceComparison = aDistance.compareTo(bDistance);
|
||||||
|
return distanceComparison != 0
|
||||||
|
? distanceComparison
|
||||||
|
: aTime.compareTo(bTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
int cachedCount = pageEntries.where((e) => e.value.isVisible).length;
|
||||||
|
|
||||||
|
// Remove old cached pages if we exceed the limit
|
||||||
|
for (final entry in pageEntries) {
|
||||||
|
if (cachedCount <= config.maxCachedPages) break;
|
||||||
|
|
||||||
|
final pageInfo = entry.value;
|
||||||
|
final distance = (entry.key - _currentVisibleIndex).abs();
|
||||||
|
|
||||||
|
// Don't unload pages within preload distance
|
||||||
|
if (distance <= config.preloadDistance) continue;
|
||||||
|
|
||||||
|
// Don't unload recently accessed pages
|
||||||
|
if (pageInfo.timeSinceAccess < config.cacheTimeout) continue;
|
||||||
|
|
||||||
|
if (pageInfo.isVisible) {
|
||||||
|
pageInfo.loadState = PageLoadState.notLoaded;
|
||||||
|
pageInfo.error = null;
|
||||||
|
cachedCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedCount != pageEntries.where((e) => e.value.isVisible).length) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force load a page immediately
|
||||||
|
Future<void> forceLoadPage(int index) async {
|
||||||
|
await _loadPage(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get memory usage statistics
|
||||||
|
Map<String, dynamic> getMemoryStats() {
|
||||||
|
final loadedCount = _pageInfoMap.values
|
||||||
|
.where((p) => p.loadState == PageLoadState.loaded)
|
||||||
|
.length;
|
||||||
|
final cachedCount = _pageInfoMap.values
|
||||||
|
.where((p) => p.loadState == PageLoadState.cached)
|
||||||
|
.length;
|
||||||
|
final errorCount = _pageInfoMap.values.where((p) => p.hasError).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'totalPages': _originalPages.length,
|
||||||
|
'loadedPages': loadedCount,
|
||||||
|
'cachedPages': cachedCount,
|
||||||
|
'errorPages': errorCount,
|
||||||
|
'currentIndex': _currentVisibleIndex,
|
||||||
|
'preloadQueueSize': _preloadQueue.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preload a range of pages
|
||||||
|
Future<void> preloadRange(int startIndex, int endIndex) async {
|
||||||
|
for (int i = startIndex; i <= endIndex && i < _originalPages.length; i++) {
|
||||||
|
if (i >= 0) {
|
||||||
|
await _loadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached pages
|
||||||
|
void clearCache() {
|
||||||
|
for (final pageInfo in _pageInfoMap.values) {
|
||||||
|
if (pageInfo.loadState != PageLoadState.loading) {
|
||||||
|
pageInfo.loadState = PageLoadState.notLoaded;
|
||||||
|
pageInfo.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:photo_view/photo_view_gallery.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_manga_list.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||||
|
|
||||||
|
/// Provides virtual page manager instances
|
||||||
|
final virtualPageManagerProvider =
|
||||||
|
Provider.family<VirtualPageManager, List<reader.UChapDataPreload>>((
|
||||||
|
ref,
|
||||||
|
pages,
|
||||||
|
) {
|
||||||
|
return VirtualPageManager(pages: pages);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Main widget for virtual reading that replaces ScrollablePositionedList
|
||||||
|
class VirtualReaderView extends ConsumerStatefulWidget {
|
||||||
|
final List<reader.UChapDataPreload> pages;
|
||||||
|
final ItemScrollController itemScrollController;
|
||||||
|
final ScrollOffsetController scrollOffsetController;
|
||||||
|
final ItemPositionsListener itemPositionsListener;
|
||||||
|
final Axis scrollDirection;
|
||||||
|
final double minCacheExtent;
|
||||||
|
final int initialScrollIndex;
|
||||||
|
final ScrollPhysics physics;
|
||||||
|
final Function(reader.UChapDataPreload data) onLongPressData;
|
||||||
|
final Function(bool) onFailedToLoadImage;
|
||||||
|
final BackgroundColor backgroundColor;
|
||||||
|
final bool isDoublePageMode;
|
||||||
|
final bool isHorizontalContinuous;
|
||||||
|
final ReaderMode readerMode;
|
||||||
|
final PhotoViewController photoViewController;
|
||||||
|
final PhotoViewScaleStateController photoViewScaleStateController;
|
||||||
|
final Alignment scalePosition;
|
||||||
|
final Function(ScaleEndDetails) onScaleEnd;
|
||||||
|
final Function(Offset) onDoubleTapDown;
|
||||||
|
final VoidCallback onDoubleTap;
|
||||||
|
final bool showDebugInfo;
|
||||||
|
// Callbacks pour gérer les transitions entre chapitres
|
||||||
|
final Function(Chapter chapter)? onChapterChanged;
|
||||||
|
final Function(int lastPageIndex)? onReachedLastPage;
|
||||||
|
|
||||||
|
const VirtualReaderView({
|
||||||
|
super.key,
|
||||||
|
required this.pages,
|
||||||
|
required this.itemScrollController,
|
||||||
|
required this.scrollOffsetController,
|
||||||
|
required this.itemPositionsListener,
|
||||||
|
required this.scrollDirection,
|
||||||
|
required this.minCacheExtent,
|
||||||
|
required this.initialScrollIndex,
|
||||||
|
required this.physics,
|
||||||
|
required this.onLongPressData,
|
||||||
|
required this.onFailedToLoadImage,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.isDoublePageMode,
|
||||||
|
required this.isHorizontalContinuous,
|
||||||
|
required this.readerMode,
|
||||||
|
required this.photoViewController,
|
||||||
|
required this.photoViewScaleStateController,
|
||||||
|
required this.scalePosition,
|
||||||
|
required this.onScaleEnd,
|
||||||
|
required this.onDoubleTapDown,
|
||||||
|
required this.onDoubleTap,
|
||||||
|
this.showDebugInfo = false,
|
||||||
|
this.onChapterChanged,
|
||||||
|
this.onReachedLastPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<VirtualReaderView> createState() => _VirtualReaderViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VirtualReaderViewState extends ConsumerState<VirtualReaderView> {
|
||||||
|
late VirtualPageManager _pageManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||||
|
|
||||||
|
// Set initial visible index
|
||||||
|
_pageManager.updateVisibleIndex(widget.initialScrollIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(VirtualReaderView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
// Update page manager if pages changed
|
||||||
|
if (widget.pages != oldWidget.pages) {
|
||||||
|
_pageManager.dispose();
|
||||||
|
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||||
|
_pageManager.updateVisibleIndex(widget.initialScrollIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageManager.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_pageManager.pageCount < widget.pages.length) {
|
||||||
|
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
PhotoViewGallery.builder(
|
||||||
|
itemCount: 1,
|
||||||
|
builder: (_, _) => PhotoViewGalleryPageOptions.customChild(
|
||||||
|
controller: widget.photoViewController,
|
||||||
|
scaleStateController: widget.photoViewScaleStateController,
|
||||||
|
basePosition: widget.scalePosition,
|
||||||
|
onScaleEnd: (context, details, controllerValue) =>
|
||||||
|
widget.onScaleEnd(details),
|
||||||
|
child: VirtualMangaList(
|
||||||
|
pageManager: _pageManager,
|
||||||
|
itemScrollController: widget.itemScrollController,
|
||||||
|
scrollOffsetController: widget.scrollOffsetController,
|
||||||
|
itemPositionsListener: widget.itemPositionsListener,
|
||||||
|
scrollDirection: widget.scrollDirection,
|
||||||
|
minCacheExtent: widget.minCacheExtent,
|
||||||
|
initialScrollIndex: widget.initialScrollIndex,
|
||||||
|
physics: widget.physics,
|
||||||
|
onLongPressData: widget.onLongPressData,
|
||||||
|
onFailedToLoadImage: widget.onFailedToLoadImage,
|
||||||
|
backgroundColor: widget.backgroundColor,
|
||||||
|
isDoublePageMode: widget.isDoublePageMode,
|
||||||
|
isHorizontalContinuous: widget.isHorizontalContinuous,
|
||||||
|
readerMode: widget.readerMode,
|
||||||
|
onDoubleTapDown: widget.onDoubleTapDown,
|
||||||
|
onDoubleTap: widget.onDoubleTap,
|
||||||
|
// Passer les callbacks pour les transitions entre chapitres
|
||||||
|
onChapterChanged: widget.onChapterChanged,
|
||||||
|
onReachedLastPage: widget.onReachedLastPage,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
// Ici on peut ajouter une logique supplémentaire si nécessaire
|
||||||
|
// Par exemple, précaching d'images
|
||||||
|
_pageManager.updateVisibleIndex(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Debug info overlay
|
||||||
|
if (widget.showDebugInfo)
|
||||||
|
VirtualPageManagerDebugInfo(pageManager: _pageManager),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixin to add virtual page manager capabilities to existing widgets
|
||||||
|
mixin VirtualPageManagerMixin<T extends ConsumerStatefulWidget>
|
||||||
|
on ConsumerState<T> {
|
||||||
|
VirtualPageManager? _virtualPageManager;
|
||||||
|
|
||||||
|
VirtualPageManager get virtualPageManager {
|
||||||
|
_virtualPageManager ??= VirtualPageManager(pages: getPages());
|
||||||
|
return _virtualPageManager!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override this method to provide the pages list
|
||||||
|
List<reader.UChapDataPreload> getPages();
|
||||||
|
|
||||||
|
/// Call this when pages change
|
||||||
|
void updateVirtualPages(List<reader.UChapDataPreload> newPages) {
|
||||||
|
_virtualPageManager?.dispose();
|
||||||
|
_virtualPageManager = VirtualPageManager(pages: newPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call this when the visible page changes
|
||||||
|
void updateVisiblePage(int index) {
|
||||||
|
virtualPageManager.updateVisibleIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_virtualPageManager?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration provider for virtual page manager
|
||||||
|
final virtualPageConfigProvider = Provider<VirtualPageConfig>((ref) {
|
||||||
|
// Get user preferences for virtual scrolling configuration
|
||||||
|
final preloadAmount = ref.watch(readerPagePreloadAmountStateProvider);
|
||||||
|
|
||||||
|
return VirtualPageConfig(
|
||||||
|
preloadDistance: preloadAmount,
|
||||||
|
maxCachedPages: preloadAmount * 3,
|
||||||
|
cacheTimeout: const Duration(minutes: 5),
|
||||||
|
enableMemoryOptimization: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider for page preload amount (renamed to avoid conflicts)
|
||||||
|
final readerPagePreloadAmountStateProvider = StateProvider<int>((ref) => 3);
|
||||||
|
|
||||||
|
/// Extension to convert ReaderMode to virtual scrolling parameters
|
||||||
|
extension ReaderModeExtension on ReaderMode {
|
||||||
|
bool get isContinuous {
|
||||||
|
return this == ReaderMode.verticalContinuous ||
|
||||||
|
this == ReaderMode.webtoon ||
|
||||||
|
this == ReaderMode.horizontalContinuous;
|
||||||
|
}
|
||||||
|
|
||||||
|
Axis get scrollDirection {
|
||||||
|
return this == ReaderMode.horizontalContinuous
|
||||||
|
? Axis.horizontal
|
||||||
|
: Axis.vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isHorizontalContinuous {
|
||||||
|
return this == ReaderMode.horizontalContinuous;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue