mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-01-11 22:40:36 +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