diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 65df6582..c7fdb495 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -18,7 +18,6 @@ import 'package:mangayomi/models/settings.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/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/providers/color_filter_provider.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/global_style.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/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/more/settings/reader/reader_screen.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:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; @@ -63,9 +61,8 @@ class MangaReaderView extends ConsumerWidget { return chapterData.when( loading: () => scaffoldWith(context, const ProgressCenter()), - error: - (error, _) => - scaffoldWith(context, Center(child: Text(error.toString()))), + error: (error, _) => + scaffoldWith(context, Center(child: Text(error.toString()))), data: (data) { final chapter = data.chapter; final model = data.pages; @@ -406,8 +403,8 @@ class _MangaChapterPageGalleryState context.l10n.save, Icons.save_outlined, () async { - final dir = - await StorageProvider().getGalleryDirectory(); + final dir = await StorageProvider() + .getGalleryDirectory(); final file = File(p.join(dir!.path, "$name.png")); file.writeAsBytesSync(imageBytes); if (context.mounted) { @@ -471,14 +468,14 @@ class _MangaChapterPageGalleryState LogicalKeyboardKey.arrowLeft => (!isLogicalKeyPressed(LogicalKeyboardKey.arrowLeft)) ? _isReverseHorizontal - ? _onBtnTapped(_currentIndex! + 1, false) - : _onBtnTapped(_currentIndex! - 1, true) + ? _onBtnTapped(_currentIndex! + 1, false) + : _onBtnTapped(_currentIndex! - 1, true) : null, LogicalKeyboardKey.arrowRight => (!isLogicalKeyPressed(LogicalKeyboardKey.arrowRight)) ? _isReverseHorizontal - ? _onBtnTapped(_currentIndex! - 1, true) - : _onBtnTapped(_currentIndex! + 1, false) + ? _onBtnTapped(_currentIndex! - 1, true) + : _onBtnTapped(_currentIndex! + 1, false) : null, LogicalKeyboardKey.arrowDown => (!isLogicalKeyPressed(LogicalKeyboardKey.arrowDown)) @@ -488,23 +485,23 @@ class _MangaChapterPageGalleryState ((!isLogicalKeyPressed(LogicalKeyboardKey.keyN) || !isLogicalKeyPressed(LogicalKeyboardKey.pageDown))) ? switch (hasNextChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ), - _ => null, - } + true => pushReplacementMangaReaderView( + context: context, + chapter: _readerController.getNextChapter(), + ), + _ => null, + } : null, LogicalKeyboardKey.keyP || LogicalKeyboardKey.pageUp => ((!isLogicalKeyPressed(LogicalKeyboardKey.keyP) || !isLogicalKeyPressed(LogicalKeyboardKey.pageUp))) ? switch (hasPrevChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ), - _ => null, - } + true => pushReplacementMangaReaderView( + context: context, + chapter: _readerController.getPrevChapter(), + ), + _ => null, + } : null, _ => null, }; @@ -531,132 +528,104 @@ class _MangaChapterPageGalleryState children: [ _isVerticalOrHorizontalContinous() ? PhotoViewGallery.builder( - itemCount: 1, - builder: - (_, _) => PhotoViewGalleryPageOptions.customChild( - controller: _photoViewController, - scaleStateController: - _photoViewScaleStateController, - basePosition: _scalePosition, - onScaleEnd: _onScaleEnd, - child: ScrollablePositionedList.separated( - scrollDirection: - isHorizontalContinuaous - ? Axis.horizontal - : Axis.vertical, - minCacheExtent: - pagePreloadAmount * context.height(1), - initialScrollIndex: - _readerController.getPageIndex(), - itemCount: - (_pageMode == PageMode.doublePage && - !isHorizontalContinuaous) - ? (_uChapDataPreload.length / 2) - .ceil() + - 1 - : _uChapDataPreload.length, - physics: const ClampingScrollPhysics(), - itemScrollController: _itemScrollController, - scrollOffsetController: _pageOffsetController, - itemPositionsListener: _itemPositionsListener, - itemBuilder: (context, index) { - if (_uChapDataPreload[index] - .isTransitionPage) { - return TransitionViewVertical( - data: _uChapDataPreload[index], - ); - } - - int index1 = index * 2 - 1; - int index2 = index1 + 1; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onDoubleTapDown: (details) { - _toggleScale(details.globalPosition); - }, - onDoubleTap: () {}, - child: - (_pageMode == PageMode.doublePage && - !isHorizontalContinuaous) - ? DoubleColummVerticalView( - datas: - index == 0 - ? [ - _uChapDataPreload[0], - null, - ] - : [ - index1 < - _uChapDataPreload - .length - ? _uChapDataPreload[index1] - : null, - index2 < - _uChapDataPreload - .length - ? _uChapDataPreload[index2] - : null, - ], - backgroundColor: - backgroundColor, - isFailedToLoadImage: (val) {}, - onLongPressData: (datas) { - _onLongPressImageDialog( - datas, - context, - ); - }, - ) - : ImageViewVertical( - data: _uChapDataPreload[index], - failedToLoadImage: (value) { - // _failedToLoadImage.value = value; - }, - onLongPressData: (datas) { - _onLongPressImageDialog( - datas, - context, - ); - }, - isHorizontal: - ref.watch( - _currentReaderMode, - ) == - ReaderMode - .horizontalContinuous, - ), - ); - }, - 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, + itemCount: 1, + builder: (_, _) => + PhotoViewGalleryPageOptions.customChild( + controller: _photoViewController, + scaleStateController: + _photoViewScaleStateController, + basePosition: _scalePosition, + onScaleEnd: _onScaleEnd, + child: VirtualReaderView( + pages: _uChapDataPreload, + itemScrollController: _itemScrollController, + scrollOffsetController: + _pageOffsetController, + itemPositionsListener: + _itemPositionsListener, + scrollDirection: isHorizontalContinuaous + ? Axis.horizontal + : Axis.vertical, + minCacheExtent: + pagePreloadAmount * context.height(1), + initialScrollIndex: _readerController + .getPageIndex(), + physics: const ClampingScrollPhysics(), + onLongPressData: (data) => + _onLongPressImageDialog(data, context), + onFailedToLoadImage: (value) { + // Handle failed image loading + if (_failedToLoadImage.value != value && + mounted) { + _failedToLoadImage.value = value; + } + }, + 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: () {}, + // Chapter transition callbacks + onChapterChanged: (newChapter) { + // Update the current chapter when a chapter change is detected + if (newChapter.id != chapter.id) { + if (mounted) { + setState(() { + _readerController = ref.read( + readerControllerProvider( + chapter: newChapter, + ).notifier, + ); + chapter = newChapter; + _isBookmarked = _readerController + .getChapterBookmarked(); + }); + } + } + }, + onReachedLastPage: (lastPageIndex) { + try { + ref + .watch( + getChapterPagesProvider( + chapter: _readerController + .getNextChapter(), + ).future, + ) + .then( + (value) => _preloadNextChapter( + value, + chapter, ), + ); + } on RangeError { + _addLastPageTransition(chapter); + } + }, + ), ), - ), - ) + ) : Material( - color: getBackgroundColor(backgroundColor), - shadowColor: getBackgroundColor(backgroundColor), - child: - (_pageMode == PageMode.doublePage && - !isHorizontalContinuaous) - ? ExtendedImageGesturePageView.builder( + color: getBackgroundColor(backgroundColor), + shadowColor: getBackgroundColor(backgroundColor), + child: + (_pageMode == PageMode.doublePage && + !isHorizontalContinuaous) + ? ExtendedImageGesturePageView.builder( controller: _extendedController, scrollDirection: _scrollDirection, reverse: _isReverseHorizontal, @@ -675,24 +644,20 @@ class _MangaChapterPageGalleryState int index1 = index * 2 - 1; int index2 = index1 + 1; - final pageList = - (index == 0 - ? [_uChapDataPreload[0], null] - : [ - index1 < - _uChapDataPreload.length - ? _uChapDataPreload[index1] - : null, - index2 < - _uChapDataPreload.length - ? _uChapDataPreload[index2] - : null, - ]); + final pageList = (index == 0 + ? [_uChapDataPreload[0], null] + : [ + index1 < _uChapDataPreload.length + ? _uChapDataPreload[index1] + : null, + index2 < _uChapDataPreload.length + ? _uChapDataPreload[index2] + : null, + ]); return DoubleColummView( - datas: - _isReverseHorizontal - ? pageList.reversed.toList() - : pageList, + datas: _isReverseHorizontal + ? pageList.reversed.toList() + : pageList, backgroundColor: backgroundColor, isFailedToLoadImage: (val) { if (_failedToLoadImage.value != val && @@ -713,7 +678,7 @@ class _MangaChapterPageGalleryState 1, onPageChanged: _onPageChanged, ) - : ExtendedImageGesturePageView.builder( + : ExtendedImageGesturePageView.builder( controller: _extendedController, scrollDirection: _scrollDirection, reverse: _isReverseHorizontal, @@ -723,10 +688,7 @@ class _MangaChapterPageGalleryState ? !(gestureDetails.totalScale! > 1.0) : true; }, - itemBuilder: ( - BuildContext context, - int index, - ) { + itemBuilder: (BuildContext context, int index) { if (_uChapDataPreload[index] .isTransitionPage) { return TransitionViewPaged( @@ -744,13 +706,13 @@ class _MangaChapterPageGalleryState state.loadingProgress; final double progress = loadingProgress - ?.expectedTotalBytes != - null - ? loadingProgress! - .cumulativeBytesLoaded / - loadingProgress - .expectedTotalBytes! - : 0; + ?.expectedTotalBytes != + null + ? loadingProgress! + .cumulativeBytesLoaded / + loadingProgress + .expectedTotalBytes! + : 0; return Container( color: getBackgroundColor( backgroundColor, @@ -779,13 +741,14 @@ class _MangaChapterPageGalleryState return ExtendedImageGesture( state, canScaleImage: (_) => true, - imageBuilder: ( - Widget image, { - ExtendedImageGestureState? - imageGestureState, - }) { - return image; - }, + imageBuilder: + ( + Widget image, { + ExtendedImageGestureState? + imageGestureState, + }) { + return image; + }, ); } if (state.extendedImageLoadState == @@ -829,18 +792,19 @@ class _MangaChapterPageGalleryState onLongPress: () { state.reLoadImage(); _failedToLoadImage - .value = false; + .value = + false; }, onTap: () { state.reLoadImage(); _failedToLoadImage - .value = false; + .value = + false; }, child: Container( decoration: BoxDecoration( - color: - context - .primaryColor, + color: context + .primaryColor, borderRadius: BorderRadius.circular( 30, @@ -911,16 +875,17 @@ class _MangaChapterPageGalleryState ); }; - _doubleClickAnimation = Tween( - begin: begin, - end: end, - ).animate( - CurvedAnimation( - curve: Curves.ease, - parent: - _doubleClickAnimationController, - ), - ); + _doubleClickAnimation = + Tween( + begin: begin, + end: end, + ).animate( + CurvedAnimation( + curve: Curves.ease, + parent: + _doubleClickAnimationController, + ), + ); _doubleClickAnimation!.addListener( _doubleClickAnimationListener, @@ -940,7 +905,7 @@ class _MangaChapterPageGalleryState itemCount: _uChapDataPreload.length, onPageChanged: _onPageChanged, ), - ), + ), _gestureRightLeft(failedToLoadImage, usePageTapZones), _gestureTopBottom(failedToLoadImage, usePageTapZones), _appBar(), @@ -969,8 +934,9 @@ class _MangaChapterPageGalleryState } Duration? _doubleTapAnimationDuration() { - int doubleTapAnimationValue = - isar.settings.getSync(227)!.doubleTapAnimationSpeed!; + int doubleTapAnimationValue = isar.settings + .getSync(227)! + .doubleTapAnimationSpeed!; if (doubleTapAnimationValue == 0) { return const Duration(milliseconds: 10); } else if (doubleTapAnimationValue == 1) { @@ -985,10 +951,10 @@ class _MangaChapterPageGalleryState _currentIndex = itemPositions.first.index; int pagesLength = (_pageMode == PageMode.doublePage && - !(ref.watch(_currentReaderMode) == - ReaderMode.horizontalContinuous)) - ? (_uChapDataPreload.length / 2).ceil() + 1 - : _uChapDataPreload.length; + !(ref.watch(_currentReaderMode) == + ReaderMode.horizontalContinuous)) + ? (_uChapDataPreload.length / 2).ceil() + 1 + : _uChapDataPreload.length; if (_currentIndex! >= 0 && _currentIndex! < pagesLength) { if (_readerController.chapter.id != _uChapDataPreload[_currentIndex!].chapter!.id) { @@ -1075,15 +1041,13 @@ class _MangaChapterPageGalleryState pageIndex: currentLength, ); - final newPages = - chapterData.uChapDataPreload - .asMap() - .entries - .map( - (entry) => - entry.value..pageIndex = currentLength + 1 + entry.key, - ) - .toList(); + final newPages = chapterData.uChapDataPreload + .asMap() + .entries + .map( + (entry) => entry.value..pageIndex = currentLength + 1 + entry.key, + ) + .toList(); if (mounted) { setState(() { @@ -1095,12 +1059,11 @@ class _MangaChapterPageGalleryState } bool _isChapterAlreadyLoaded(Chapter chapter) { - final existingIdentifiers = - _uChapDataPreload - .map((item) => item.chapter) - .where((ch) => ch != null) - .map((ch) => _getChapterIdentifier(ch!)) - .toSet(); + final existingIdentifiers = _uChapDataPreload + .map((item) => item.chapter) + .where((ch) => ch != null) + .map((ch) => _getChapterIdentifier(ch!)) + .toSet(); return existingIdentifiers.contains(_getChapterIdentifier(chapter)); } @@ -1250,10 +1213,10 @@ class _MangaChapterPageGalleryState } else { animatePageTransitions ? _itemScrollController.scrollTo( - curve: Curves.ease, - index: index, - duration: const Duration(milliseconds: 150), - ) + curve: Curves.ease, + index: index, + duration: const Duration(milliseconds: 150), + ) : _itemScrollController.jumpTo(index: index); } } @@ -1265,10 +1228,10 @@ class _MangaChapterPageGalleryState } else { animatePageTransitions ? _extendedController.animateToPage( - index, - duration: const Duration(milliseconds: 150), - curve: Curves.ease, - ) + index, + duration: const Duration(milliseconds: 150), + curve: Curves.ease, + ) : _extendedController.jumpToPage(index); } } @@ -1283,10 +1246,10 @@ class _MangaChapterPageGalleryState } else { animatePageTransitions ? _itemScrollController.scrollTo( - curve: Curves.ease, - index: index, - duration: const Duration(milliseconds: 150), - ) + curve: Curves.ease, + index: index, + duration: const Duration(milliseconds: 150), + ) : _itemScrollController.jumpTo(index: index); } } else { @@ -1296,10 +1259,10 @@ class _MangaChapterPageGalleryState } else { animatePageTransitions ? _extendedController.animateToPage( - index, - duration: const Duration(milliseconds: 150), - curve: Curves.ease, - ) + index, + duration: const Duration(milliseconds: 150), + curve: Curves.ease, + ) : _extendedController.jumpToPage(index); } } @@ -1350,10 +1313,9 @@ class _MangaChapterPageGalleryState int index = (_pageMode == PageMode.doublePage && - !(ref.watch(_currentReaderMode) == - ReaderMode.horizontalContinuous)) - ? (_currentIndex! / 2).ceil() - : _currentIndex!; + !(ref.watch(_currentReaderMode) == ReaderMode.horizontalContinuous)) + ? (_currentIndex! / 2).ceil() + : _currentIndex!; ref.read(_currentReaderMode.notifier).state = value; if (value == ReaderMode.vertical) { if (mounted) { @@ -1406,8 +1368,8 @@ class _MangaChapterPageGalleryState ).future, ) .then((value) { - _uChapDataPreload[index] = - _uChapDataPreload[index]..cropImage = value; + _uChapDataPreload[index] = _uChapDataPreload[index] + ..cropImage = value; }); if (mounted) { setState(() {}); @@ -1446,14 +1408,13 @@ class _MangaChapterPageGalleryState Widget _appBar() { final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - double height = - _isView - ? Platform.isIOS - ? 120 - : !fullScreenReader && !isDesktop - ? 55 - : 80 - : 0; + double height = _isView + ? Platform.isIOS + ? 120 + : !fullScreenReader && !isDesktop + ? 55 + : 80 + : 0; return Positioned( top: 0, child: AnimatedContainer( @@ -1540,34 +1501,28 @@ class _MangaChapterPageGalleryState Widget _autoScrollPlayPauseBtn() { return _isVerticalOrHorizontalContinous() ? Positioned( - bottom: 0, - right: 0, - child: - !_isView - ? ValueListenableBuilder( + bottom: 0, + right: 0, + child: !_isView + ? ValueListenableBuilder( valueListenable: _autoScrollPage, - builder: - (context, valueT, child) => - valueT - ? ValueListenableBuilder( - valueListenable: _autoScroll, - builder: - (context, value, child) => IconButton( - onPressed: () { - _autoPagescroll(); - _autoScroll.value = !value; - }, - icon: Icon( - value - ? Icons.pause_circle - : Icons.play_circle, - ), - ), - ) - : const SizedBox.shrink(), + builder: (context, valueT, child) => valueT + ? ValueListenableBuilder( + valueListenable: _autoScroll, + builder: (context, value, child) => IconButton( + onPressed: () { + _autoPagescroll(); + _autoScroll.value = !value; + }, + icon: Icon( + value ? Icons.pause_circle : Icons.play_circle, + ), + ), + ) + : const SizedBox.shrink(), ) - : const SizedBox.shrink(), - ) + : const SizedBox.shrink(), + ) : const SizedBox.shrink(); } @@ -1600,30 +1555,25 @@ class _MangaChapterPageGalleryState radius: 23, backgroundColor: _backgroundColor(context), child: IconButton( - onPressed: - hasPrevChapter - ? () { - pushReplacementMangaReaderView( - context: context, - chapter: - _readerController.getPrevChapter(), - ); - } - : null, + onPressed: hasPrevChapter + ? () { + pushReplacementMangaReaderView( + context: context, + chapter: _readerController.getPrevChapter(), + ); + } + : null, icon: Transform.scale( scaleX: 1, child: Icon( Icons.skip_previous_rounded, - color: - hasPrevChapter - ? Theme.of( - context, - ).textTheme.bodyLarge!.color - : Theme.of(context) - .textTheme - .bodyLarge! - .color! - .withValues(alpha: 0.4), + color: hasPrevChapter + ? Theme.of(context).textTheme.bodyLarge!.color + : Theme.of(context) + .textTheme + .bodyLarge! + .color! + .withValues(alpha: 0.4), ), ), ), @@ -1694,17 +1644,15 @@ class _MangaChapterPageGalleryState }, onChangeEnd: (newValue) { try { - final index = - _uChapDataPreload - .firstWhere( - (element) => - element.chapter == - chapter && - element.index == - newValue - .toInt(), - ) - .pageIndex; + final index = _uChapDataPreload + .firstWhere( + (element) => + element.chapter == + chapter && + element.index == + newValue.toInt(), + ) + .pageIndex; _onBtnTapped( index!, @@ -1715,26 +1663,22 @@ class _MangaChapterPageGalleryState }, divisions: _readerController.getPageLength( - _chapterUrlModel - .pageUrls, - ) == - 1 - ? null - : _pageMode == - PageMode.doublePage - ? ((_readerController.getPageLength( - _chapterUrlModel - .pageUrls, - )) / - 2) - .ceil() + - 1 - : _readerController - .getPageLength( - _chapterUrlModel - .pageUrls, - ) - - 1, + _chapterUrlModel.pageUrls, + ) == + 1 + ? null + : _pageMode == PageMode.doublePage + ? ((_readerController.getPageLength( + _chapterUrlModel + .pageUrls, + )) / + 2) + .ceil() + + 1 + : _readerController.getPageLength( + _chapterUrlModel.pageUrls, + ) - + 1, value: min( (currentIndex).toDouble(), (_pageMode == PageMode.doublePage && @@ -1743,19 +1687,19 @@ class _MangaChapterPageGalleryState ) == ReaderMode .horizontalContinuous)) - ? ((_readerController - .getPageLength( - _chapterUrlModel - .pageUrls, - )) / - 2) - .ceil() + - 1 + ? ((_readerController.getPageLength( + _chapterUrlModel + .pageUrls, + )) / + 2) + .ceil() + + 1 : (_readerController - .getPageLength( - _chapterUrlModel.pageUrls, - ) - .toDouble()), + .getPageLength( + _chapterUrlModel + .pageUrls, + ) + .toDouble()), ), label: _currentIndexLabel( currentIndex, @@ -1763,27 +1707,27 @@ class _MangaChapterPageGalleryState min: 0, max: (_pageMode == - PageMode.doublePage && - !(ref.watch( - _currentReaderMode, - ) == - ReaderMode - .horizontalContinuous)) - ? (((_readerController.getPageLength( - _chapterUrlModel - .pageUrls, - )) / - 2) - .ceil() + - 1) - .toDouble() - : (_readerController - .getPageLength( - _chapterUrlModel - .pageUrls, - ) - - 1) - .toDouble(), + PageMode.doublePage && + !(ref.watch( + _currentReaderMode, + ) == + ReaderMode + .horizontalContinuous)) + ? (((_readerController.getPageLength( + _chapterUrlModel + .pageUrls, + )) / + 2) + .ceil() + + 1) + .toDouble() + : (_readerController + .getPageLength( + _chapterUrlModel + .pageUrls, + ) - + 1) + .toDouble(), ), ); }, @@ -1815,30 +1759,25 @@ class _MangaChapterPageGalleryState radius: 23, backgroundColor: _backgroundColor(context), child: IconButton( - onPressed: - hasNextChapter - ? () { - pushReplacementMangaReaderView( - context: context, - chapter: - _readerController.getNextChapter(), - ); - } - : null, + onPressed: hasNextChapter + ? () { + pushReplacementMangaReaderView( + context: context, + chapter: _readerController.getNextChapter(), + ); + } + : null, icon: Transform.scale( scaleX: 1, child: Icon( Icons.skip_next_rounded, - color: - hasNextChapter - ? Theme.of( - context, - ).textTheme.bodyLarge!.color - : Theme.of(context) - .textTheme - .bodyLarge! - .color! - .withValues(alpha: 0.4), + color: hasNextChapter + ? Theme.of(context).textTheme.bodyLarge!.color + : Theme.of(context) + .textTheme + .bodyLarge! + .color! + .withValues(alpha: 0.4), // size: 17, ), ), @@ -1864,32 +1803,30 @@ class _MangaChapterPageGalleryState ref.read(_currentReaderMode.notifier).state = value; _setReaderMode(value, ref); }, - itemBuilder: - (context) => [ - for (var mode in ReaderMode.values) - PopupMenuItem( - value: mode, - child: Row( - children: [ - Icon( - Icons.check, - color: - readerMode == mode - ? Colors.white - : Colors.transparent, - ), - const SizedBox(width: 7), - Text( - getReaderModeName(mode, context), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], + itemBuilder: (context) => [ + for (var mode in ReaderMode.values) + PopupMenuItem( + value: mode, + child: Row( + children: [ + Icon( + Icons.check, + color: readerMode == mode + ? Colors.white + : Colors.transparent, ), - ), - ], + const SizedBox(width: 7), + Text( + getReaderModeName(mode, context), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ], ), Consumer( builder: (context, ref, child) { @@ -1933,21 +1870,20 @@ class _MangaChapterPageGalleryState _onBtnTapped( _pageMode == PageMode.onePage ? (_geCurrentIndex( - _uChapDataPreload[_currentIndex!] - .index!, - ) / - 2) - .ceil() + _uChapDataPreload[_currentIndex!] + .index!, + ) / + 2) + .ceil() : _geCurrentIndex( - _uChapDataPreload[_currentIndex!].index!, - ), + _uChapDataPreload[_currentIndex!].index!, + ), true, isSlide: true, ); - newPageMode = - _pageMode == PageMode.onePage - ? PageMode.doublePage - : PageMode.onePage; + newPageMode = _pageMode == PageMode.onePage + ? PageMode.doublePage + : PageMode.onePage; _readerController.setPageMode(newPageMode); if (mounted) { @@ -1987,22 +1923,22 @@ class _MangaChapterPageGalleryState ? const SizedBox.shrink() : ref.watch(_showPagesNumber) ? Align( - alignment: Alignment.bottomCenter, - child: Text( - '${_currentIndexLabel(currentIndex)} / ${_readerController.getPageLength(_chapterUrlModel.pageUrls)}', - style: const TextStyle( - color: Colors.white, - fontSize: 20.0, - shadows: [ - Shadow(offset: Offset(-1, -1), blurRadius: 1), - Shadow(offset: Offset(1, -1), blurRadius: 1), - Shadow(offset: Offset(1, 1), blurRadius: 1), - Shadow(offset: Offset(-1, 1), blurRadius: 1), - ], + alignment: Alignment.bottomCenter, + child: Text( + '${_currentIndexLabel(currentIndex)} / ${_readerController.getPageLength(_chapterUrlModel.pageUrls)}', + style: const TextStyle( + color: Colors.white, + fontSize: 20.0, + shadows: [ + Shadow(offset: Offset(-1, -1), blurRadius: 1), + Shadow(offset: Offset(1, -1), blurRadius: 1), + Shadow(offset: Offset(1, 1), blurRadius: 1), + Shadow(offset: Offset(-1, 1), blurRadius: 1), + ], + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - ) + ) : const SizedBox.shrink(); }, ); @@ -2070,55 +2006,53 @@ class _MangaChapterPageGalleryState _isViewFunction(); } }, - onDoubleTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onSecondaryTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, + onDoubleTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onSecondaryTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, - onSecondaryTap: - _isVerticalOrHorizontalContinous() ? () {} : null, + onSecondaryTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, ), ), /// center region Expanded( flex: 2, - child: - failedToLoadImage - ? SizedBox( - width: context.width(1), - height: context.height(0.7), - ) - : GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - _isViewFunction(); - }, - onDoubleTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onSecondaryTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onDoubleTap: - _isVerticalOrHorizontalContinous() ? () {} : null, - onSecondaryTap: - _isVerticalOrHorizontalContinous() ? () {} : null, - ), + child: failedToLoadImage + ? SizedBox( + width: context.width(1), + height: context.height(0.7), + ) + : GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + _isViewFunction(); + }, + onDoubleTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onSecondaryTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onDoubleTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, + onSecondaryTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, + ), ), /// right region @@ -2137,21 +2071,20 @@ class _MangaChapterPageGalleryState _isViewFunction(); } }, - onDoubleTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onSecondaryTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, + onDoubleTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onSecondaryTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, - onSecondaryTap: - _isVerticalOrHorizontalContinous() ? () {} : null, + onSecondaryTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, ), ), ], @@ -2177,21 +2110,20 @@ class _MangaChapterPageGalleryState ? _onBtnTapped(_currentIndex! - 1, true) : _isViewFunction(); }, - onDoubleTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onSecondaryTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, + onDoubleTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onSecondaryTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, - onSecondaryTap: - _isVerticalOrHorizontalContinous() ? () {} : null, + onSecondaryTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, ), ), @@ -2210,21 +2142,20 @@ class _MangaChapterPageGalleryState ? _onBtnTapped(_currentIndex! + 1, false) : _isViewFunction(); }, - onDoubleTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, - onSecondaryTapDown: - _isVerticalOrHorizontalContinous() - ? (details) { - _toggleScale(details.globalPosition); - } - : null, + onDoubleTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, + onSecondaryTapDown: _isVerticalOrHorizontalContinous() + ? (details) { + _toggleScale(details.globalPosition); + } + : null, onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null, - onSecondaryTap: - _isVerticalOrHorizontalContinous() ? () {} : null, + onSecondaryTap: _isVerticalOrHorizontalContinous() + ? () {} + : null, ), ), ], @@ -2341,22 +2272,21 @@ class _MangaChapterPageGalleryState if (valueT) ValueListenableBuilder( valueListenable: _pageOffset, - builder: - (context, value, child) => Slider( - min: 2.0, - max: 30.0, - divisions: max(28, 3), - value: value, - onChanged: (val) { - _pageOffset.value = val; - }, - onChangeEnd: (val) { - _readerController.setAutoScroll( - valueT, - val, - ); - }, - ), + builder: (context, value, child) => Slider( + min: 2.0, + max: 30.0, + divisions: max(28, 3), + value: value, + onChanged: (val) { + _pageOffset.value = val; + }, + onChangeEnd: (val) { + _readerController.setAutoScroll( + valueT, + val, + ); + }, + ), ), ], ); @@ -2406,16 +2336,15 @@ class _MangaChapterPageGalleryState .set(ScaleType.values[value.index]); }, value: scaleType, - list: - ScaleType.values.where((scale) { - try { - return getScaleTypeNames(context).contains( - getScaleTypeNames(context)[scale.index], - ); - } catch (_) { - return false; - } - }).toList(), + list: ScaleType.values.where((scale) { + try { + return getScaleTypeNames( + context, + ).contains(getScaleTypeNames(context)[scale.index]); + } catch (_) { + return false; + } + }).toList(), itemText: (scale) { return getScaleTypeNames(context)[scale.index]; }, @@ -2642,26 +2571,25 @@ class CustomPopupMenuButton extends StatelessWidget { offset: Offset.fromDirection(1), color: Colors.black, onSelected: onSelected, - itemBuilder: - (context) => [ - for (var d in list) - PopupMenuItem( - value: d, - child: Row( - children: [ - Icon( - Icons.check, - color: d == value ? Colors.white : Colors.transparent, - ), - const SizedBox(width: 7), - Text( - itemText(d), - style: const TextStyle(color: Colors.white), - ), - ], + itemBuilder: (context) => [ + for (var d in list) + PopupMenuItem( + value: d, + child: Row( + children: [ + Icon( + Icons.check, + color: d == value ? Colors.white : Colors.transparent, ), - ), - ], + const SizedBox(width: 7), + Text( + itemText(d), + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: Row( diff --git a/lib/modules/manga/reader/virtual_scrolling/virtual_manga_list.dart b/lib/modules/manga/reader/virtual_scrolling/virtual_manga_list.dart new file mode 100644 index 00000000..22274bcc --- /dev/null +++ b/lib/modules/manga/reader/virtual_scrolling/virtual_manga_list.dart @@ -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 createState() => _VirtualMangaListState(); +} + +class _VirtualMangaListState extends ConsumerState { + 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 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), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart b/lib/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart new file mode 100644 index 00000000..c954eeb8 --- /dev/null +++ b/lib/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart @@ -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 _originalPages; + final VirtualPageConfig config; + final Map _pageInfoMap = {}; + final Set _preloadQueue = {}; + + int _currentVisibleIndex = 0; + Timer? _cleanupTimer; + + VirtualPageManager({ + required List 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 _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 forceLoadPage(int index) async { + await _loadPage(index); + } + + /// Get memory usage statistics + Map 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 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(); + } +} diff --git a/lib/modules/manga/reader/virtual_scrolling/virtual_reader_view.dart b/lib/modules/manga/reader/virtual_scrolling/virtual_reader_view.dart new file mode 100644 index 00000000..136a90b6 --- /dev/null +++ b/lib/modules/manga/reader/virtual_scrolling/virtual_reader_view.dart @@ -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>(( + ref, + pages, + ) { + return VirtualPageManager(pages: pages); + }); + +/// Main widget for virtual reading that replaces ScrollablePositionedList +class VirtualReaderView extends ConsumerStatefulWidget { + final List 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 createState() => _VirtualReaderViewState(); +} + +class _VirtualReaderViewState extends ConsumerState { + 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 + on ConsumerState { + VirtualPageManager? _virtualPageManager; + + VirtualPageManager get virtualPageManager { + _virtualPageManager ??= VirtualPageManager(pages: getPages()); + return _virtualPageManager!; + } + + /// Override this method to provide the pages list + List getPages(); + + /// Call this when pages change + void updateVirtualPages(List 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((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((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; + } +}