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:
Moustapha Kodjo Amadou 2025-05-28 14:56:05 +01:00
parent c3ac07fa97
commit c24df38506
4 changed files with 1341 additions and 560 deletions

View file

@ -18,7 +18,6 @@ import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/anime/widgets/desktop.dart'; import 'package:mangayomi/modules/anime/widgets/desktop.dart';
import 'package:mangayomi/modules/manga/reader/providers/crop_borders_provider.dart'; import 'package:mangayomi/modules/manga/reader/providers/crop_borders_provider.dart';
import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart'; import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart';
import 'package:mangayomi/modules/manga/reader/double_columm_view_vertical.dart';
import 'package:mangayomi/modules/manga/reader/double_columm_view_center.dart'; import 'package:mangayomi/modules/manga/reader/double_columm_view_center.dart';
import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart'; import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart';
import 'package:mangayomi/modules/manga/reader/widgets/color_filter_widget.dart'; import 'package:mangayomi/modules/manga/reader/widgets/color_filter_widget.dart';
@ -35,13 +34,12 @@ import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:mangayomi/utils/extensions/others.dart'; import 'package:mangayomi/utils/extensions/others.dart';
import 'package:mangayomi/utils/global_style.dart'; import 'package:mangayomi/utils/global_style.dart';
import 'package:mangayomi/modules/manga/reader/image_view_paged.dart'; import 'package:mangayomi/modules/manga/reader/image_view_paged.dart';
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart'; import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_paged.dart'; import 'package:mangayomi/modules/manga/reader/widgets/transition_view_paged.dart';
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart'; import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart';
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_reader_view.dart';
import 'package:mangayomi/modules/widgets/progress_center.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
@ -63,8 +61,7 @@ class MangaReaderView extends ConsumerWidget {
return chapterData.when( return chapterData.when(
loading: () => scaffoldWith(context, const ProgressCenter()), loading: () => scaffoldWith(context, const ProgressCenter()),
error: error: (error, _) =>
(error, _) =>
scaffoldWith(context, Center(child: Text(error.toString()))), scaffoldWith(context, Center(child: Text(error.toString()))),
data: (data) { data: (data) {
final chapter = data.chapter; final chapter = data.chapter;
@ -406,8 +403,8 @@ class _MangaChapterPageGalleryState
context.l10n.save, context.l10n.save,
Icons.save_outlined, Icons.save_outlined,
() async { () async {
final dir = final dir = await StorageProvider()
await StorageProvider().getGalleryDirectory(); .getGalleryDirectory();
final file = File(p.join(dir!.path, "$name.png")); final file = File(p.join(dir!.path, "$name.png"));
file.writeAsBytesSync(imageBytes); file.writeAsBytesSync(imageBytes);
if (context.mounted) { if (context.mounted) {
@ -532,121 +529,93 @@ class _MangaChapterPageGalleryState
_isVerticalOrHorizontalContinous() _isVerticalOrHorizontalContinous()
? PhotoViewGallery.builder( ? PhotoViewGallery.builder(
itemCount: 1, itemCount: 1,
builder: builder: (_, _) =>
(_, _) => PhotoViewGalleryPageOptions.customChild( PhotoViewGalleryPageOptions.customChild(
controller: _photoViewController, controller: _photoViewController,
scaleStateController: scaleStateController:
_photoViewScaleStateController, _photoViewScaleStateController,
basePosition: _scalePosition, basePosition: _scalePosition,
onScaleEnd: _onScaleEnd, onScaleEnd: _onScaleEnd,
child: ScrollablePositionedList.separated( child: VirtualReaderView(
scrollDirection: pages: _uChapDataPreload,
isHorizontalContinuaous itemScrollController: _itemScrollController,
scrollOffsetController:
_pageOffsetController,
itemPositionsListener:
_itemPositionsListener,
scrollDirection: isHorizontalContinuaous
? Axis.horizontal ? Axis.horizontal
: Axis.vertical, : Axis.vertical,
minCacheExtent: minCacheExtent:
pagePreloadAmount * context.height(1), pagePreloadAmount * context.height(1),
initialScrollIndex: initialScrollIndex: _readerController
_readerController.getPageIndex(), .getPageIndex(),
itemCount:
(_pageMode == PageMode.doublePage &&
!isHorizontalContinuaous)
? (_uChapDataPreload.length / 2)
.ceil() +
1
: _uChapDataPreload.length,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
itemScrollController: _itemScrollController, onLongPressData: (data) =>
scrollOffsetController: _pageOffsetController, _onLongPressImageDialog(data, context),
itemPositionsListener: _itemPositionsListener, onFailedToLoadImage: (value) {
itemBuilder: (context, index) { // Handle failed image loading
if (_uChapDataPreload[index] if (_failedToLoadImage.value != value &&
.isTransitionPage) { mounted) {
return TransitionViewVertical( _failedToLoadImage.value = value;
data: _uChapDataPreload[index],
);
} }
int index1 = index * 2 - 1;
int index2 = index1 + 1;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTapDown: (details) {
_toggleScale(details.globalPosition);
}, },
backgroundColor: backgroundColor,
isDoublePageMode:
_pageMode == PageMode.doublePage &&
!isHorizontalContinuaous,
isHorizontalContinuous:
isHorizontalContinuaous,
readerMode: ref.watch(_currentReaderMode)!,
photoViewController: _photoViewController,
photoViewScaleStateController:
_photoViewScaleStateController,
scalePosition: _scalePosition,
onScaleEnd: (details) => _onScaleEnd(
context,
details,
_photoViewController.value,
),
onDoubleTapDown: (offset) =>
_toggleScale(offset),
onDoubleTap: () {}, onDoubleTap: () {},
child: // Chapter transition callbacks
(_pageMode == PageMode.doublePage && onChapterChanged: (newChapter) {
!isHorizontalContinuaous) // Update the current chapter when a chapter change is detected
? DoubleColummVerticalView( if (newChapter.id != chapter.id) {
datas: if (mounted) {
index == 0 setState(() {
? [ _readerController = ref.read(
_uChapDataPreload[0], readerControllerProvider(
null, chapter: newChapter,
] ).notifier,
: [
index1 <
_uChapDataPreload
.length
? _uChapDataPreload[index1]
: null,
index2 <
_uChapDataPreload
.length
? _uChapDataPreload[index2]
: null,
],
backgroundColor:
backgroundColor,
isFailedToLoadImage: (val) {},
onLongPressData: (datas) {
_onLongPressImageDialog(
datas,
context,
); );
chapter = newChapter;
_isBookmarked = _readerController
.getChapterBookmarked();
});
}
}
}, },
onReachedLastPage: (lastPageIndex) {
try {
ref
.watch(
getChapterPagesProvider(
chapter: _readerController
.getNextChapter(),
).future,
) )
: ImageViewVertical( .then(
data: _uChapDataPreload[index], (value) => _preloadNextChapter(
failedToLoadImage: (value) { value,
// _failedToLoadImage.value = value; chapter,
},
onLongPressData: (datas) {
_onLongPressImageDialog(
datas,
context,
);
},
isHorizontal:
ref.watch(
_currentReaderMode,
) ==
ReaderMode
.horizontalContinuous,
), ),
); );
} on RangeError {
_addLastPageTransition(chapter);
}
}, },
separatorBuilder:
(_, __) =>
ref.watch(_currentReaderMode) ==
ReaderMode.webtoon
? const SizedBox.shrink()
: ref.watch(_currentReaderMode) ==
ReaderMode
.horizontalContinuous
? VerticalDivider(
color: getBackgroundColor(
backgroundColor,
),
width: 6,
)
: Divider(
color: getBackgroundColor(
backgroundColor,
),
height: 6,
),
), ),
), ),
) )
@ -675,22 +644,18 @@ class _MangaChapterPageGalleryState
int index1 = index * 2 - 1; int index1 = index * 2 - 1;
int index2 = index1 + 1; int index2 = index1 + 1;
final pageList = final pageList = (index == 0
(index == 0
? [_uChapDataPreload[0], null] ? [_uChapDataPreload[0], null]
: [ : [
index1 < index1 < _uChapDataPreload.length
_uChapDataPreload.length
? _uChapDataPreload[index1] ? _uChapDataPreload[index1]
: null, : null,
index2 < index2 < _uChapDataPreload.length
_uChapDataPreload.length
? _uChapDataPreload[index2] ? _uChapDataPreload[index2]
: null, : null,
]); ]);
return DoubleColummView( return DoubleColummView(
datas: datas: _isReverseHorizontal
_isReverseHorizontal
? pageList.reversed.toList() ? pageList.reversed.toList()
: pageList, : pageList,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
@ -723,10 +688,7 @@ class _MangaChapterPageGalleryState
? !(gestureDetails.totalScale! > 1.0) ? !(gestureDetails.totalScale! > 1.0)
: true; : true;
}, },
itemBuilder: ( itemBuilder: (BuildContext context, int index) {
BuildContext context,
int index,
) {
if (_uChapDataPreload[index] if (_uChapDataPreload[index]
.isTransitionPage) { .isTransitionPage) {
return TransitionViewPaged( return TransitionViewPaged(
@ -779,7 +741,8 @@ class _MangaChapterPageGalleryState
return ExtendedImageGesture( return ExtendedImageGesture(
state, state,
canScaleImage: (_) => true, canScaleImage: (_) => true,
imageBuilder: ( imageBuilder:
(
Widget image, { Widget image, {
ExtendedImageGestureState? ExtendedImageGestureState?
imageGestureState, imageGestureState,
@ -829,17 +792,18 @@ class _MangaChapterPageGalleryState
onLongPress: () { onLongPress: () {
state.reLoadImage(); state.reLoadImage();
_failedToLoadImage _failedToLoadImage
.value = false; .value =
false;
}, },
onTap: () { onTap: () {
state.reLoadImage(); state.reLoadImage();
_failedToLoadImage _failedToLoadImage
.value = false; .value =
false;
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: context
context
.primaryColor, .primaryColor,
borderRadius: borderRadius:
BorderRadius.circular( BorderRadius.circular(
@ -911,7 +875,8 @@ class _MangaChapterPageGalleryState
); );
}; };
_doubleClickAnimation = Tween( _doubleClickAnimation =
Tween(
begin: begin, begin: begin,
end: end, end: end,
).animate( ).animate(
@ -969,8 +934,9 @@ class _MangaChapterPageGalleryState
} }
Duration? _doubleTapAnimationDuration() { Duration? _doubleTapAnimationDuration() {
int doubleTapAnimationValue = int doubleTapAnimationValue = isar.settings
isar.settings.getSync(227)!.doubleTapAnimationSpeed!; .getSync(227)!
.doubleTapAnimationSpeed!;
if (doubleTapAnimationValue == 0) { if (doubleTapAnimationValue == 0) {
return const Duration(milliseconds: 10); return const Duration(milliseconds: 10);
} else if (doubleTapAnimationValue == 1) { } else if (doubleTapAnimationValue == 1) {
@ -1075,13 +1041,11 @@ class _MangaChapterPageGalleryState
pageIndex: currentLength, pageIndex: currentLength,
); );
final newPages = final newPages = chapterData.uChapDataPreload
chapterData.uChapDataPreload
.asMap() .asMap()
.entries .entries
.map( .map(
(entry) => (entry) => entry.value..pageIndex = currentLength + 1 + entry.key,
entry.value..pageIndex = currentLength + 1 + entry.key,
) )
.toList(); .toList();
@ -1095,8 +1059,7 @@ class _MangaChapterPageGalleryState
} }
bool _isChapterAlreadyLoaded(Chapter chapter) { bool _isChapterAlreadyLoaded(Chapter chapter) {
final existingIdentifiers = final existingIdentifiers = _uChapDataPreload
_uChapDataPreload
.map((item) => item.chapter) .map((item) => item.chapter)
.where((ch) => ch != null) .where((ch) => ch != null)
.map((ch) => _getChapterIdentifier(ch!)) .map((ch) => _getChapterIdentifier(ch!))
@ -1350,8 +1313,7 @@ class _MangaChapterPageGalleryState
int index = int index =
(_pageMode == PageMode.doublePage && (_pageMode == PageMode.doublePage &&
!(ref.watch(_currentReaderMode) == !(ref.watch(_currentReaderMode) == ReaderMode.horizontalContinuous))
ReaderMode.horizontalContinuous))
? (_currentIndex! / 2).ceil() ? (_currentIndex! / 2).ceil()
: _currentIndex!; : _currentIndex!;
ref.read(_currentReaderMode.notifier).state = value; ref.read(_currentReaderMode.notifier).state = value;
@ -1406,8 +1368,8 @@ class _MangaChapterPageGalleryState
).future, ).future,
) )
.then((value) { .then((value) {
_uChapDataPreload[index] = _uChapDataPreload[index] = _uChapDataPreload[index]
_uChapDataPreload[index]..cropImage = value; ..cropImage = value;
}); });
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
@ -1446,8 +1408,7 @@ class _MangaChapterPageGalleryState
Widget _appBar() { Widget _appBar() {
final fullScreenReader = ref.watch(fullScreenReaderStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
double height = double height = _isView
_isView
? Platform.isIOS ? Platform.isIOS
? 120 ? 120
: !fullScreenReader && !isDesktop : !fullScreenReader && !isDesktop
@ -1542,25 +1503,19 @@ class _MangaChapterPageGalleryState
? Positioned( ? Positioned(
bottom: 0, bottom: 0,
right: 0, right: 0,
child: child: !_isView
!_isView
? ValueListenableBuilder( ? ValueListenableBuilder(
valueListenable: _autoScrollPage, valueListenable: _autoScrollPage,
builder: builder: (context, valueT, child) => valueT
(context, valueT, child) =>
valueT
? ValueListenableBuilder( ? ValueListenableBuilder(
valueListenable: _autoScroll, valueListenable: _autoScroll,
builder: builder: (context, value, child) => IconButton(
(context, value, child) => IconButton(
onPressed: () { onPressed: () {
_autoPagescroll(); _autoPagescroll();
_autoScroll.value = !value; _autoScroll.value = !value;
}, },
icon: Icon( icon: Icon(
value value ? Icons.pause_circle : Icons.play_circle,
? Icons.pause_circle
: Icons.play_circle,
), ),
), ),
) )
@ -1600,13 +1555,11 @@ class _MangaChapterPageGalleryState
radius: 23, radius: 23,
backgroundColor: _backgroundColor(context), backgroundColor: _backgroundColor(context),
child: IconButton( child: IconButton(
onPressed: onPressed: hasPrevChapter
hasPrevChapter
? () { ? () {
pushReplacementMangaReaderView( pushReplacementMangaReaderView(
context: context, context: context,
chapter: chapter: _readerController.getPrevChapter(),
_readerController.getPrevChapter(),
); );
} }
: null, : null,
@ -1614,11 +1567,8 @@ class _MangaChapterPageGalleryState
scaleX: 1, scaleX: 1,
child: Icon( child: Icon(
Icons.skip_previous_rounded, Icons.skip_previous_rounded,
color: color: hasPrevChapter
hasPrevChapter ? Theme.of(context).textTheme.bodyLarge!.color
? Theme.of(
context,
).textTheme.bodyLarge!.color
: Theme.of(context) : Theme.of(context)
.textTheme .textTheme
.bodyLarge! .bodyLarge!
@ -1694,15 +1644,13 @@ class _MangaChapterPageGalleryState
}, },
onChangeEnd: (newValue) { onChangeEnd: (newValue) {
try { try {
final index = final index = _uChapDataPreload
_uChapDataPreload
.firstWhere( .firstWhere(
(element) => (element) =>
element.chapter == element.chapter ==
chapter && chapter &&
element.index == element.index ==
newValue newValue.toInt(),
.toInt(),
) )
.pageIndex; .pageIndex;
@ -1715,13 +1663,11 @@ class _MangaChapterPageGalleryState
}, },
divisions: divisions:
_readerController.getPageLength( _readerController.getPageLength(
_chapterUrlModel _chapterUrlModel.pageUrls,
.pageUrls,
) == ) ==
1 1
? null ? null
: _pageMode == : _pageMode == PageMode.doublePage
PageMode.doublePage
? ((_readerController.getPageLength( ? ((_readerController.getPageLength(
_chapterUrlModel _chapterUrlModel
.pageUrls, .pageUrls,
@ -1729,10 +1675,8 @@ class _MangaChapterPageGalleryState
2) 2)
.ceil() + .ceil() +
1 1
: _readerController : _readerController.getPageLength(
.getPageLength( _chapterUrlModel.pageUrls,
_chapterUrlModel
.pageUrls,
) - ) -
1, 1,
value: min( value: min(
@ -1743,8 +1687,7 @@ class _MangaChapterPageGalleryState
) == ) ==
ReaderMode ReaderMode
.horizontalContinuous)) .horizontalContinuous))
? ((_readerController ? ((_readerController.getPageLength(
.getPageLength(
_chapterUrlModel _chapterUrlModel
.pageUrls, .pageUrls,
)) / )) /
@ -1753,7 +1696,8 @@ class _MangaChapterPageGalleryState
1 1
: (_readerController : (_readerController
.getPageLength( .getPageLength(
_chapterUrlModel.pageUrls, _chapterUrlModel
.pageUrls,
) )
.toDouble()), .toDouble()),
), ),
@ -1815,13 +1759,11 @@ class _MangaChapterPageGalleryState
radius: 23, radius: 23,
backgroundColor: _backgroundColor(context), backgroundColor: _backgroundColor(context),
child: IconButton( child: IconButton(
onPressed: onPressed: hasNextChapter
hasNextChapter
? () { ? () {
pushReplacementMangaReaderView( pushReplacementMangaReaderView(
context: context, context: context,
chapter: chapter: _readerController.getNextChapter(),
_readerController.getNextChapter(),
); );
} }
: null, : null,
@ -1829,11 +1771,8 @@ class _MangaChapterPageGalleryState
scaleX: 1, scaleX: 1,
child: Icon( child: Icon(
Icons.skip_next_rounded, Icons.skip_next_rounded,
color: color: hasNextChapter
hasNextChapter ? Theme.of(context).textTheme.bodyLarge!.color
? Theme.of(
context,
).textTheme.bodyLarge!.color
: Theme.of(context) : Theme.of(context)
.textTheme .textTheme
.bodyLarge! .bodyLarge!
@ -1864,8 +1803,7 @@ class _MangaChapterPageGalleryState
ref.read(_currentReaderMode.notifier).state = value; ref.read(_currentReaderMode.notifier).state = value;
_setReaderMode(value, ref); _setReaderMode(value, ref);
}, },
itemBuilder: itemBuilder: (context) => [
(context) => [
for (var mode in ReaderMode.values) for (var mode in ReaderMode.values)
PopupMenuItem( PopupMenuItem(
value: mode, value: mode,
@ -1873,8 +1811,7 @@ class _MangaChapterPageGalleryState
children: [ children: [
Icon( Icon(
Icons.check, Icons.check,
color: color: readerMode == mode
readerMode == mode
? Colors.white ? Colors.white
: Colors.transparent, : Colors.transparent,
), ),
@ -1944,8 +1881,7 @@ class _MangaChapterPageGalleryState
true, true,
isSlide: true, isSlide: true,
); );
newPageMode = newPageMode = _pageMode == PageMode.onePage
_pageMode == PageMode.onePage
? PageMode.doublePage ? PageMode.doublePage
: PageMode.onePage; : PageMode.onePage;
@ -2070,29 +2006,27 @@ class _MangaChapterPageGalleryState
_isViewFunction(); _isViewFunction();
} }
}, },
onDoubleTapDown: onDoubleTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onSecondaryTapDown: onSecondaryTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
onSecondaryTap: onSecondaryTap: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous() ? () {} : null, ? () {}
: null,
), ),
), ),
/// center region /// center region
Expanded( Expanded(
flex: 2, flex: 2,
child: child: failedToLoadImage
failedToLoadImage
? SizedBox( ? SizedBox(
width: context.width(1), width: context.width(1),
height: context.height(0.7), height: context.height(0.7),
@ -2102,22 +2036,22 @@ class _MangaChapterPageGalleryState
onTap: () { onTap: () {
_isViewFunction(); _isViewFunction();
}, },
onDoubleTapDown: onDoubleTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onSecondaryTapDown: onSecondaryTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onDoubleTap: onDoubleTap: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous() ? () {} : null, ? () {}
onSecondaryTap: : null,
_isVerticalOrHorizontalContinous() ? () {} : null, onSecondaryTap: _isVerticalOrHorizontalContinous()
? () {}
: null,
), ),
), ),
@ -2137,21 +2071,20 @@ class _MangaChapterPageGalleryState
_isViewFunction(); _isViewFunction();
} }
}, },
onDoubleTapDown: onDoubleTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onSecondaryTapDown: onSecondaryTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
onSecondaryTap: onSecondaryTap: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous() ? () {} : null, ? () {}
: null,
), ),
), ),
], ],
@ -2177,21 +2110,20 @@ class _MangaChapterPageGalleryState
? _onBtnTapped(_currentIndex! - 1, true) ? _onBtnTapped(_currentIndex! - 1, true)
: _isViewFunction(); : _isViewFunction();
}, },
onDoubleTapDown: onDoubleTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onSecondaryTapDown: onSecondaryTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
onSecondaryTap: onSecondaryTap: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous() ? () {} : null, ? () {}
: null,
), ),
), ),
@ -2210,21 +2142,20 @@ class _MangaChapterPageGalleryState
? _onBtnTapped(_currentIndex! + 1, false) ? _onBtnTapped(_currentIndex! + 1, false)
: _isViewFunction(); : _isViewFunction();
}, },
onDoubleTapDown: onDoubleTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onSecondaryTapDown: onSecondaryTapDown: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous()
? (details) { ? (details) {
_toggleScale(details.globalPosition); _toggleScale(details.globalPosition);
} }
: null, : null,
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
onSecondaryTap: onSecondaryTap: _isVerticalOrHorizontalContinous()
_isVerticalOrHorizontalContinous() ? () {} : null, ? () {}
: null,
), ),
), ),
], ],
@ -2341,8 +2272,7 @@ class _MangaChapterPageGalleryState
if (valueT) if (valueT)
ValueListenableBuilder( ValueListenableBuilder(
valueListenable: _pageOffset, valueListenable: _pageOffset,
builder: builder: (context, value, child) => Slider(
(context, value, child) => Slider(
min: 2.0, min: 2.0,
max: 30.0, max: 30.0,
divisions: max(28, 3), divisions: max(28, 3),
@ -2406,12 +2336,11 @@ class _MangaChapterPageGalleryState
.set(ScaleType.values[value.index]); .set(ScaleType.values[value.index]);
}, },
value: scaleType, value: scaleType,
list: list: ScaleType.values.where((scale) {
ScaleType.values.where((scale) {
try { try {
return getScaleTypeNames(context).contains( return getScaleTypeNames(
getScaleTypeNames(context)[scale.index], context,
); ).contains(getScaleTypeNames(context)[scale.index]);
} catch (_) { } catch (_) {
return false; return false;
} }
@ -2642,8 +2571,7 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
offset: Offset.fromDirection(1), offset: Offset.fromDirection(1),
color: Colors.black, color: Colors.black,
onSelected: onSelected, onSelected: onSelected,
itemBuilder: itemBuilder: (context) => [
(context) => [
for (var d in list) for (var d in list)
PopupMenuItem( PopupMenuItem(
value: d, value: d,

View file

@ -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),
),
],
),
),
);
},
);
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}